diff --git a/RELEASE_NOTES b/RELEASE_NOTES index 99964060e8f..1d6a47fa223 100644 --- a/RELEASE_NOTES +++ b/RELEASE_NOTES @@ -368,6 +368,155 @@ Incompatible changes to one rather than kept, so a tag built as ":INT:0", or through the (tag, type) constructor which used to leave the count at zero, now reads one element instead of none. +- All Java drivers now select array elements with one shared notation, written + before the data type: `[n]` for a single element, `[lo..hi]` for an + inclusive range, an optional `;base` for an array the PLC declares as + starting somewhere other than zero, and one bracket per dimension. The + dimensions of one array may also be written comma-separated inside a single + bracket - `[1..2,3..4]` is the same as `[1..2][3..4]` - which is the form + Allen-Bradley and others use; addresses are always rendered back in the + one-bracket-per-dimension form. See the "Addressing arrays" page. + + This replaces four incompatible spellings. `[4]` meant "four elements" in + seven tag classes and "the fifth element" in two; it now means one element + everywhere, and a count is written as a range. Addresses in the old form no + longer parse, and the error names the address to write instead - so an + upgrade reports the change rather than quietly returning different data. + The affected forms, by driver: + + S7 %DB42:28.0:BYTE[4] -> %DB42:28.0[0..3]:BYTE + S7 (string) %DB1:0:STRING(20)[4] -> %DB1:0[0..3]:STRING(20) + Modbus holding-register:1:INT[4] -> holding-register:1[0..3]:INT + SLMP D100:INT[4] -> D100[0..3]:INT + ADS (direct) 0x4020/0:DINT[4] -> 0x4020/0[0..3]:DINT + EtherNet/IP myArray[0]:DINT:4 -> myArray[0..3]:DINT + Profinet tag:INT[4] -> tag[0..3]:INT + Profinet-NG 1.2.INPUT.0:INT[4] -> 1.2.INPUT.0[0..3]:INT + Simulated RANDOM/foo:INT[4] -> RANDOM/foo[0..3]:INT + + OPC-UA addresses are unchanged - its implementation is the one the shared + notation was extracted from - and ADS and UMAS symbolic addresses keep + their existing form while gaining ranges. +- Firmata is the one driver whose addresses change meaning silently. They + carry no data type (`3[4]`), so the brackets did not move and there is + nothing to reject: `3[4]` used to read four pins starting at pin 3 and now + reads one pin, the fifth. Rewrite these as `3[0..3]`. +- An address that selects nothing now asks for the whole value rather than a + single element. For a scalar that is unchanged; for an array it is every + element, on the drivers that can determine the extent from the device + (OPC-UA, ADS, UMAS). The others read one element as before, because their + addresses are memory offsets with no declared array at them. +- A single index and a one-element range are no longer the same thing. + `myTag[4]` selects one element and yields a scalar, while `myTag[4..4]` + yields a list of one. `PlcTag.getArrayInfo()` reports the shape of the + value received - empty for a scalar, one entry per dimension for an array - + so a consumer can tell the two apart without knowing the protocol. +- `ArrayInfo` gains `getBase()` and `isRange()`, both as default methods, so + existing implementations keep compiling. Its javadoc described `[6]` as a + six-element array, which was never what the drivers did and is not what the + notation means. +- EtherNet/IP rejects an array index above 255 while parsing the address; a + CIP MemberID carries a `uint 8`. A range may run past it, since the request + carries a start and a count, but it cannot begin there. +- ADS and UMAS verify a `;base` written in the address against the bounds the + device declares, and report a disagreement. The device is authoritative; a + base that differs means the address was written against a different layout, + which would otherwise read silently shifted data. +- ADS rejects an address that names a member of an array without saying which + element - `MAIN.g_arr.member` on an array `g_arr`. It previously resolved + against the first element and reported the result as though it were the + whole path. +- Selecting array elements over UMAS is reported as UNSUPPORTED rather than + returning the whole variable. The driver has no per-element arithmetic yet; + the address parses, and the refusal is explicit. +- Fixed `getArrayInfo()` reporting one element too many on the ADS direct and + Firmata drivers, whose inclusive bounds were built from the element count + rather than the last index. +- Fixed a direct ADS array selection transferring every element it asked for + and decoding only the first, in both PLC4J and PLC4Go. The size of the + request was multiplied by the element count while the decoder was given no + shape, so `0x4020/0[0..3]:DINT` returned one value for four elements' worth + of bytes - a well-formed answer to a question nobody asked. +- Fixed a symbolic ADS selection being ignored in PLC4Go. `MAIN.arr[1..4]` + resolved like `MAIN.arr`: the whole array, from its original offset. The + selection now moves the read to the first selected element and transfers + only what it spans, across as many dimensions as the address names + (`MAIN.grid[3,1..3]`), and a selection outside what the device declares is + refused rather than approximated. +- Fixed the shape of a partly selected multi-dimensional ADS array in PLC4J. + `MAIN.grid[1..2]` on an `ARRAY [0..9,0..4]` reported two elements rather + than two rows of five: the dimensions the selection did not name were + dropped from the shape while their bytes were still transferred. They are + selected whole, and are part of the value. +- A dimension of an ADS selection written as a bare index now collapses, + where before every named dimension added a level of list. `grid[3,1..3]` is + a list of three, not a list of one list of three, and `grid[3,2]` is a + scalar. This is the same rule the notation states for a single dimension. +- Fixed SLMP reporting a one-element range as a scalar in PLC4J. `D100[4..4]` + now returns a list of one, as `D100[4]` returns a scalar and as PLC4Go's + SLMP driver already did. Its shape came from the element count, which + cannot express the difference. +- PLC4Go now uses the same array notation as PLC4J, so one address means one + thing in either language. The grammar, the rules and the rendering are the + ones described above; the two share a specification rather than code, and + the Go parser is tested against the Java cases directly. + + The forms that changed, by driver: + + S7 %M100:INT[10] -> %M100[0..9]:INT + S7 (string) %DB69.DBX68:WSTRING[3] -> %DB69.DBX68[0..2]:WSTRING + Modbus holding-register:1:INT[4] -> holding-register:1[0..3]:INT + SLMP D100:INT[4] -> D100[0..3]:INT + EtherNet/IP %rate:DINT:4 -> %rate[0..3]:DINT + Simulated RANDOM/foo:INT[4] -> RANDOM/foo[0..3]:INT + KNXnet/IP 1.2.3#4B1C:UINT[4] -> 1.2.3#4B1C[0..3]:UINT + KNXnet/IP 1.2.3#11/1/1[4] -> 1.2.3#11/1/1[0..3] + + Addresses in the old form no longer parse, and the error names the address + to write instead - with two exceptions, below, where the address parses + either way and only its meaning moves. +- Two Go drivers change the meaning of addresses that still parse, so there is + nothing to reject and nothing to warn about at runtime: + + * ADS `[n]` was a *count* of n elements and is now the element at index n. + `MAIN.g_arr[3]` read three elements and now reads one. Rewrite as + `MAIN.g_arr[0..2]`. This also means Go and Java ADS now agree about the + same address; they did not before. + * Firmata `[n]` was a run of n pins and is now the pin at index n, exactly + as in PLC4J. `digital:2[3]` read three pins from pin 2 and now reads + pin 5. Rewrite as `digital:2[0..2]`. +- ADS also drops the `[a:b]` start-and-count form, which had no counterpart in + PLC4J. `MAIN.g_arr[2:4]` is written `MAIN.g_arr[2..5]`. +- A count of zero no longer has a spelling. Several Go drivers accepted `[0]` + and rejected it as "quantity must be greater than zero"; a range is written + with the indices it covers, so there is no way to ask for none, and `[0]` + now selects the first element. +- `ArrayInfo` bounds are inclusive in PLC4Go, as they are in PLC4J: + `GetSize()` returns `UpperBound - LowerBound + 1`. They were exclusive, + documented as a deliberate divergence, so `[0..7]` reported eight elements + in Java and seven in Go - the same disagreement about the same address that + this change exists to remove. Code reading `GetUpperBound()` directly must + be revisited. +- `ArrayInfo` gains `GetBase()` and `IsRange()`. Go has no default methods, so + any implementation outside PLC4Go must add them. +- Addresses that a driver rendered back are now spelled the way its parser + reads them. Several never round-tripped: BACnet/IP rendered `:` where the + syntax wants `,`, gave every property a leading `:`, and printed the address + of the pointer holding an array index rather than the index; KNXnet/IP + device addresses rendered `/` where the syntax wants `.`; the ADS direct + form printed its index group as decimal digits behind an `0x` prefix, so + 16416 came back as `0x16416` - a different address; and the S7 tag rendered + as "0:INT[8]", naming neither the memory area nor the offset it read. +- Fixed the Go BACnet/IP driver asking for one element fewer than requested + when a read carried an element count, which followed from the bounds + becoming inclusive. +- C-Bus addresses are unchanged. Its brackets carry the arguments of a CAL + command (`recall=[param, count]`), not a selection appended to an address. +- KNXnet/IP group addresses are unchanged. Their brackets hold a set of group + addresses to match (`[1-3,5]`), not an array selection. Only the two device + address forms, which carry a real element count, moved to the new notation. +- BACnet/IP addresses are unchanged. Its bracket is a property array index, + which already meant what the notation says an index means. Changed Maven Coordinates ------------------------- @@ -406,6 +555,23 @@ utilities). These are new artifacts, not renames. Bug Fixes --------- +- Fixed the Java S7 driver's tags reporting no address at all: + "getAddressString()" returned null, so anything carrying a tag as a string - + a log line, a browse result, a serialized request - got nothing from an S7 + tag. It now spells the address the way the parser reads it back, including + the declared length of a fixed-length string and the counter number of a + COUNTER address, which is stored split across the byte and bit offsets. +- The Open Protocol driver's tag class now reports that it has no tag + addressing yet instead of returning null. "OpenProtocolTag.of()" handed a + null tag to callers of "prepareTag()", so the failure surfaced later as a + NullPointerException; it now throws PlcInvalidTagException, matching the + driver's tag handler, which already rejected every address. +- Fixed the Go S7 driver reading a fixed-length string from the wrong data + block. The long-form address ("%DB69.DBX68:STRING(10)") built its tag with + a hard-coded block number of zero, so it read DB0 and reported the result + as though it had come from DB69. The short form ("%DB69:68:STRING(10)") + and every non-string address were unaffected, as is PLC4J, which parses + the block number for all of them. - Fixed serialization in the 'plc4x' proxy driver's message codec, which did not configure the buffer integer/string encodings under SPI3 and failed to serialize any message. diff --git a/plc4go/assets/testing/protocols/eip/DriverTestsuite.xml b/plc4go/assets/testing/protocols/eip/DriverTestsuite.xml index e69722f371d..25169cba249 100644 --- a/plc4go/assets/testing/protocols/eip/DriverTestsuite.xml +++ b/plc4go/assets/testing/protocols/eip/DriverTestsuite.xml @@ -1103,7 +1103,7 @@ hurz -
%rate:DINT:4
+
%rate[0..3]:DINT
diff --git a/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuite.xml b/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuite.xml index bca1b8f01db..cc6976df485 100644 --- a/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuite.xml +++ b/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuite.xml @@ -130,7 +130,7 @@ hurz -
holding-register:1:REAL[2]
+
holding-register:1[0..1]:REAL
@@ -482,7 +482,7 @@ hurz -
holding-register:1:REAL[2]
+
holding-register:1[0..1]:REAL
3.1415927 3.1415927
diff --git a/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuiteOptimized.xml b/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuiteOptimized.xml index 9be68785066..f5c36fc98ed 100644 --- a/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuiteOptimized.xml +++ b/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuiteOptimized.xml @@ -134,7 +134,7 @@ hurz -
holding-register:1:REAL[2]
+
holding-register:1[0..1]:REAL
@@ -452,7 +452,7 @@ hurz -
holding-register:1:REAL[2]
+
holding-register:1[0..1]:REAL
3.1415927 3.1415927
diff --git a/plc4go/internal/ads/Browser.go b/plc4go/internal/ads/Browser.go index 808b0328e01..a60d2760dd3 100644 --- a/plc4go/internal/ads/Browser.go +++ b/plc4go/internal/ads/Browser.go @@ -115,6 +115,12 @@ func (m *Connection) filterDataTypes(parentName string, currentType driverModel. arrayInfo = append(arrayInfo, &spiModel.DefaultArrayInfo{ LowerBound: ai.GetLowerBound(), UpperBound: ai.GetUpperBound(), + // The device declared this an array, which is what Range records; without it the shape + // rule reads the dimension as a bare index and reports the array as a scalar. The + // declared lower bound is also the base, so an address using the PLC's own indices + // lines up with it. + Base: ai.GetLowerBound(), + Range: true, }) } foundTag := spiModel.NewDefaultPlcBrowseItem( diff --git a/plc4go/internal/ads/Connection.go b/plc4go/internal/ads/Connection.go index 9bf6c9e79ad..8f42882833c 100644 --- a/plc4go/internal/ads/Connection.go +++ b/plc4go/internal/ads/Connection.go @@ -384,6 +384,20 @@ func (m *Connection) directTagFor(ctx context.Context, tag apiModel.PlcTag) (*mo } directTag.DataType = dataType } + // A direct address names a location whose type is the element type, so its selection is the + // whole of its shape. The offset was already moved to the first selected element while the + // address was parsed (see TagHandler.applySelectionOffset); what is added here is the shape + // to decode, without which the extra elements were transferred and then dropped. + if len(directTag.SelectedArrayInfo) == 0 && len(directTag.ArrayInfo) > 0 { + elements := uint32(1) + for _, dimension := range directTag.ArrayInfo { + elements *= dimension.GetSize() + } + directTag.SelectedArrayInfo = []readWriteModel.AdsDataTypeArrayInfo{ + readWriteModel.NewAdsDataTypeArrayInfo(uint32(directTag.ArrayInfo[0].GetLowerBound()), elements), + } + directTag.SelectedSizeInBytes = directTag.DataType.GetSize() * elements + } return &directTag, nil } @@ -411,17 +425,126 @@ func (m *Connection) resolveSymbolicTag(ctx context.Context, symbolicTag model.S return nil, fmt.Errorf("couldn't find data type with name %s for tag with address %s", dataTypeName, symbolName) } // Start resolving the address. - return m.resolveSymbolicAddress(ctx, addressParts, dataType, symbol.GetGroup(), symbol.GetOffset()) + resolved, err := m.resolveSymbolicAddress(ctx, addressParts, dataType, symbol.GetGroup(), symbol.GetOffset()) + if err != nil { + return nil, err + } + // The trailing selection is not part of the symbolic path - it says which elements of the + // resolved location to read. Applying it here is what makes MAIN.arr[1..4] read those four + // elements; without it the resolved location was the whole array at its original offset, and + // the selection was parsed, rendered and silently discarded. + return m.applySymbolicSelection(resolved, symbolicTag.ArrayInfo, symbolicAddress) +} + +// applySymbolicSelection narrows a resolved location to the elements the address selected. +// +// A selection is refused rather than approximated when it cannot be applied exactly: reading the +// wrong elements is indistinguishable from reading the right ones once the values come back. +func (m *Connection) applySymbolicSelection(tag *model.DirectPlcTag, selection []apiModel.ArrayInfo, address string) (*model.DirectPlcTag, error) { + if len(selection) == 0 { + return tag, nil + } + declared := tag.ArrayInfo + if len(declared) == 0 { + return nil, fmt.Errorf("address %s selects elements, but the PLC does not declare %s as an array", + address, tag.DataType.GetMainName()) + } + if len(selection) != len(declared) { + return nil, fmt.Errorf("address %s selects %d dimension(s) of %s, which the PLC declares "+ + "with %d - name every dimension or none of them", + address, len(selection), tag.DataType.GetMainName(), len(declared)) + } + itemType, err := m.arrayItemTypeFor(tag.DataType) + if err != nil { + return nil, err + } + itemSize := itemType.GetSize() + if itemSize == 0 { + return nil, fmt.Errorf("address %s selects elements of %s, whose size the data type table "+ + "gives as zero", address, tag.DataType.GetMainName()) + } + for dimension := range selection { + if err := checkSelectedDimension(selection, declared, dimension, address); err != nil { + return nil, err + } + } + + // Row-major strides: a step along a dimension skips every element of the dimensions inside + // it. The innermost stride is one element, and each dimension outwards multiplies by the + // number of elements the dimension inside it declares. + stride := itemSize + elements := uint32(1) + var shape []readWriteModel.AdsDataTypeArrayInfo + for dimension := len(selection) - 1; dimension >= 0; dimension-- { + tag.IndexOffset += (selection[dimension].GetLowerBound() - declared[dimension].GetLowerBound()) * stride + stride *= declared[dimension].GetSize() + elements *= selection[dimension].GetSize() + if selection[dimension].IsRange() { + // A range is an array even when it spans one element, so the shape follows what the + // address wrote rather than the count. A bare index contributes no level - it moves + // the start and collapses, which is what makes it a scalar. + shape = append([]readWriteModel.AdsDataTypeArrayInfo{ + readWriteModel.NewAdsDataTypeArrayInfo( + selection[dimension].GetLowerBound(), selection[dimension].GetSize()), + }, shape...) + } + } + + tag.DataType = itemType + tag.ValueType, tag.StringLength = m.getPlcValueForAdsDataTypeTableEntry(itemType) + tag.SelectedSizeInBytes = itemSize * elements + tag.SelectedArrayInfo = shape + if len(shape) > 0 { + tag.ArrayInfo = selection + } else { + tag.ArrayInfo = nil + } + return tag, nil +} + +// checkSelectedDimension holds one dimension of a selection to what the PLC declares, and to what +// a single read can express. +// +// One read covers one contiguous run of memory. Scanning outwards from the innermost dimension, +// that means every dimension inside a dimension selecting more than one element must be selected +// whole: on an ARRAY [0..9,0..4], "[0..9,1..3]" names ten separate three-element runs, and the +// contiguous block of thirty elements starting at [0,1] that a single read would return is not +// what was asked for. Refusing says so; returning that block would not. +func checkSelectedDimension(selection, declared []apiModel.ArrayInfo, dimension int, address string) error { + selected, available := selection[dimension], declared[dimension] + if selected.GetLowerBound() < available.GetLowerBound() || selected.GetUpperBound() > available.GetUpperBound() { + return fmt.Errorf("address %s selects [%d..%d] of a dimension the PLC declares as [%d..%d]", + address, selected.GetLowerBound(), selected.GetUpperBound(), + available.GetLowerBound(), available.GetUpperBound()) + } + if dimension == 0 || selected.GetSize() == available.GetSize() { + return nil + } + for outer := 0; outer < dimension; outer++ { + if selection[outer].GetSize() > 1 { + return fmt.Errorf("address %s selects part of dimension %d while dimension %d spans "+ + "%d elements, which is not one contiguous read - select the whole of the inner "+ + "dimension, or one element of the outer one", + address, dimension, outer, selection[outer].GetSize()) + } + } + return nil } func (m *Connection) resolveSymbolicAddress(ctx context.Context, addressParts []string, curDataType readWriteModel.AdsDataTypeTableEntry, indexGroup uint32, indexOffset uint32) (*model.DirectPlcTag, error) { // If we've reached then end of the resolution, return the final entry. if len(addressParts) == 0 { + // The dimensions the symbol table declares. They are arrays by definition - the device + // says so - which is what Range records: without it the shape rule would read them as a + // bare index and report the whole array as a scalar. The declared lower bound is also + // the base, so an address written with the PLC's own indices lines up with it. var arrayInfo []apiModel.ArrayInfo for _, adsArrayInfo := range curDataType.GetArrayInfo() { arrayInfo = append(arrayInfo, &spiModel.DefaultArrayInfo{ LowerBound: adsArrayInfo.GetLowerBound(), UpperBound: adsArrayInfo.GetUpperBound(), + Base: adsArrayInfo.GetLowerBound(), + Range: true, }) } plcValueType, stringLength := m.getPlcValueForAdsDataTypeTableEntry(curDataType) diff --git a/plc4go/internal/ads/DriverContext.go b/plc4go/internal/ads/DriverContext.go index 4b81eea7543..d4a6ff94bc9 100644 --- a/plc4go/internal/ads/DriverContext.go +++ b/plc4go/internal/ads/DriverContext.go @@ -161,6 +161,12 @@ func (m *DriverContext) getArrayInfoForDataTypeTableEntry(entry driverModel.AdsD arrayInfo := spiModel.DefaultArrayInfo{ LowerBound: adsArrayInfo.GetLowerBound(), UpperBound: adsArrayInfo.GetUpperBound(), + // The device declared this an array, which is what Range records; without it the shape + // rule reads the dimension as a bare index and reports the array as a scalar. The + // declared lower bound is also the base, so an address using the PLC's own indices + // lines up with it. + Base: adsArrayInfo.GetLowerBound(), + Range: true, } arrayInfos = append(arrayInfos, &arrayInfo) } diff --git a/plc4go/internal/ads/Reader.go b/plc4go/internal/ads/Reader.go index 858764f4553..ba71355e886 100644 --- a/plc4go/internal/ads/Reader.go +++ b/plc4go/internal/ads/Reader.go @@ -81,7 +81,7 @@ func (m *Connection) singleRead(ctx context.Context, readRequest apiModel.PlcRea utils.DeliverResult(m.log, result, spiModel.NewDefaultPlcReadRequestResult(readRequest, nil, errors.Errorf("panic-ed %v. Stack: %s", err, debug.Stack()))) } }() - response, err := m.ExecuteAdsReadRequest(ctx, directAdsTag.IndexGroup, directAdsTag.IndexOffset, directAdsTag.DataType.GetSize()) + response, err := m.ExecuteAdsReadRequest(ctx, directAdsTag.IndexGroup, directAdsTag.IndexOffset, directAdsTag.TransferSizeInBytes()) if err != nil { utils.DeliverResult(m.log, result, spiModel.NewDefaultPlcReadRequestResult( readRequest, @@ -101,7 +101,7 @@ func (m *Connection) singleRead(ctx context.Context, readRequest apiModel.PlcRea for _, tagName := range readRequest.GetTagNames() { m.log.Debug().Str("tagName", tagName).Msg("get a tag from request with name") // Try to parse the value - plcValue, err := m.parsePlcValue(directAdsTag.DataType, directAdsTag.DataType.GetArrayInfo(), rb) + plcValue, err := m.parsePlcValue(directAdsTag.DataType, directAdsTag.DecodeArrayInfo(), rb) if err != nil { m.log.Error().Err(err).Msg("Error parsing plc value") responseCodes[tagName] = apiModel.PlcResponseCode_INTERNAL_ERROR @@ -136,22 +136,17 @@ func (m *Connection) multiRead(ctx context.Context, readRequest apiModel.PlcRead directAdsTags[tagName] = directAdsTag - // Size of one element. - size := directAdsTag.DataType.GetSize() - - // Calculate how many elements in total we'll be reading. - arraySize := uint32(1) - if len(tag.GetArrayInfo()) > 0 { - for _, arrayInfo := range tag.GetArrayInfo() { - arraySize = arraySize * arrayInfo.GetSize() - } - } + // How many bytes this tag transfers, which the resolved tag knows: the whole of what its + // type declares, or just the part the address selected out of it. Reading it from the + // request tag's own selection instead double-counted a symbolic selection, whose declared + // type is already the whole array. + size := directAdsTag.TransferSizeInBytes() // Status code + payload size - expectedTagSize := 4 + (size * arraySize) + expectedTagSize := 4 + size expectedResponseDataSize += expectedTagSize - requestItems = append(requestItems, driverModel.NewAdsMultiRequestItemRead(directAdsTag.IndexGroup, directAdsTag.IndexOffset, size*arraySize)) + requestItems = append(requestItems, driverModel.NewAdsMultiRequestItemRead(directAdsTag.IndexGroup, directAdsTag.IndexOffset, size)) } response, err := m.ExecuteAdsReadWriteRequest(ctx, uint32(driverModel.ReservedIndexGroups_ADSIGRP_MULTIPLE_READ), uint32(len(directAdsTags)), expectedResponseDataSize, requestItems, nil) @@ -190,7 +185,7 @@ func (m *Connection) multiRead(ctx context.Context, readRequest apiModel.PlcRead directAdsTag := directAdsTags[tagName] m.log.Debug().Str("tagName", tagName).Msg("get a tag from request with name") // Try to parse the value - plcValue, err := m.parsePlcValue(directAdsTag.DataType, directAdsTag.DataType.GetArrayInfo(), rb) + plcValue, err := m.parsePlcValue(directAdsTag.DataType, directAdsTag.DecodeArrayInfo(), rb) if err != nil { m.log.Error().Err(err).Msg("Error parsing plc value") responseCodes[tagName] = apiModel.PlcResponseCode_INTERNAL_ERROR @@ -208,6 +203,27 @@ func (m *Connection) multiRead(ctx context.Context, readRequest apiModel.PlcRead )) } +// arrayItemTypeFor is the type of one element of a shape being decoded or encoded. +// +// A declared array names its element type in its own name ("ARRAY [0..9] OF DINT"), and that is +// where the shape usually comes from. A shape can also come from the address instead - a +// selection out of a location whose type is already the element type, as every direct array tag +// is ("0x4020/0[0..3]:DINT" resolves to DINT). Such a type names no element type because it *is* +// the element type, which is what the fallback says. +func (m *Connection) arrayItemTypeFor(dataType driverModel.AdsDataTypeTableEntry) (driverModel.AdsDataTypeTableEntry, error) { + name := dataType.GetSecondaryName() + separator := strings.Index(name, " OF ") + if separator < 0 { + return dataType, nil + } + itemTypeName := name[separator+4:] + itemType, ok := m.driverContext.dataTypeTable[itemTypeName] + if !ok { + return nil, fmt.Errorf("couldn't resolve array item type %s", itemTypeName) + } + return itemType, nil +} + func (m *Connection) parsePlcValue(dataType driverModel.AdsDataTypeTableEntry, arrayInfo []driverModel.AdsDataTypeArrayInfo, rb utils.ReadBufferByteBased) (apiValues.PlcValue, error) { ctx := context.TODO() // Decode the data according to the information from the request @@ -215,10 +231,9 @@ func (m *Connection) parsePlcValue(dataType driverModel.AdsDataTypeTableEntry, a if len(arrayInfo) > 0 { // This is an Array/List type. curArrayInfo := arrayInfo[0] - arrayItemTypeName := dataType.GetSecondaryName()[strings.Index(dataType.GetSecondaryName(), " OF ")+4:] - arrayItemType, ok := m.driverContext.dataTypeTable[arrayItemTypeName] - if !ok { - return nil, fmt.Errorf("couldn't resolve array item type %s", arrayItemTypeName) + arrayItemType, err := m.arrayItemTypeFor(dataType) + if err != nil { + return nil, err } var plcValues []apiValues.PlcValue for i := uint32(0); i < curArrayInfo.GetNumElements(); i++ { diff --git a/plc4go/internal/ads/SymbolicSelection_test.go b/plc4go/internal/ads/SymbolicSelection_test.go new file mode 100644 index 00000000000..26bd880eab0 --- /dev/null +++ b/plc4go/internal/ads/SymbolicSelection_test.go @@ -0,0 +1,268 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package ads + +import ( + "context" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x/plc4go/internal/ads/model" + apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" + driverModel "github.com/apache/plc4x/plc4go/protocols/ads/readwrite/model" + spiModel "github.com/apache/plc4x/plc4go/spi/model" +) + +// A device declaring one symbol, MAIN.arr, as ARRAY [0..9] OF DINT at group 0x4020, offset 100. +// Ten four-byte elements, so every offset in these tests is checkable by hand. +func connectionWithArraySymbol() *Connection { + dint := driverModel.NewAdsDataTypeTableEntryBuilder(). + WithSize(4). + WithMainName("DINT"). + WithSecondaryName("DINT"). + MustBuild() + array := driverModel.NewAdsDataTypeTableEntryBuilder(). + WithSize(40). + WithMainName("ARRAY [0..9] OF DINT"). + WithSecondaryName("ARRAY [0..9] OF DINT"). + WithArrayDimensions(1). + WithArrayInfo(driverModel.NewAdsDataTypeArrayInfo(0, 10)). + MustBuild() + symbol := driverModel.NewAdsSymbolTableEntryBuilder(). + WithGroup(0x4020). + WithOffset(100). + WithSize(40). + WithName("MAIN.arr"). + WithDataTypeName("ARRAY [0..9] OF DINT"). + MustBuild() + + return &Connection{ + log: zerolog.Nop(), + driverContext: &DriverContext{ + dataTypeTable: map[string]driverModel.AdsDataTypeTableEntry{ + "DINT": dint, "ARRAY [0..9] OF DINT": array, + }, + symbolTable: map[string]driverModel.AdsSymbolTableEntry{"MAIN.arr": symbol}, + }, + } +} + +func resolve(t *testing.T, address string, selection []apiModel.ArrayInfo) (*model.DirectPlcTag, error) { + t.Helper() + return connectionWithArraySymbol().resolveSymbolicTag(context.Background(), + model.SymbolicPlcTag{SymbolicAddress: address, PlcTag: model.PlcTag{ArrayInfo: selection}}) +} + +// Without a selection the whole array is read from where the symbol table puts it - the behaviour +// every existing address relies on. +func TestSymbolicResolution_withoutASelectionReadsTheWholeArray(t *testing.T) { + tag, err := resolve(t, "MAIN.arr", nil) + + require.NoError(t, err) + assert.Equal(t, uint32(100), tag.IndexOffset) + assert.Equal(t, uint32(40), tag.TransferSizeInBytes(), "all ten elements") + require.Len(t, tag.DecodeArrayInfo(), 1) + assert.Equal(t, uint32(10), tag.DecodeArrayInfo()[0].GetNumElements()) +} + +// A range moves the start to the first selected element and transfers only what it spans. Before +// this was applied the selection was parsed and then dropped: the read covered the whole array +// from offset 100, and the caller got ten values where four were asked for. +func TestSymbolicResolution_aRangeNarrowsTheReadToItsElements(t *testing.T) { + tag, err := resolve(t, "MAIN.arr", []apiModel.ArrayInfo{ + &spiModel.DefaultArrayInfo{LowerBound: 1, UpperBound: 4, Range: true}, + }) + + require.NoError(t, err) + assert.Equal(t, uint32(104), tag.IndexOffset, "one four-byte element past the start") + assert.Equal(t, uint32(16), tag.TransferSizeInBytes(), "four elements") + require.Len(t, tag.DecodeArrayInfo(), 1) + assert.Equal(t, uint32(4), tag.DecodeArrayInfo()[0].GetNumElements()) + assert.Equal(t, "DINT", tag.DataType.GetMainName(), "an element, not the array") +} + +// A bare index is a scalar, and a range of one is a list of one. The two select the same element +// and differ only in shape, which is the distinction the notation exists to make. +func TestSymbolicResolution_aBareIndexIsAScalarAndAOneElementRangeIsNot(t *testing.T) { + scalar, err := resolve(t, "MAIN.arr", []apiModel.ArrayInfo{ + &spiModel.DefaultArrayInfo{LowerBound: 3, UpperBound: 3}, + }) + require.NoError(t, err) + + listOfOne, err := resolve(t, "MAIN.arr", []apiModel.ArrayInfo{ + &spiModel.DefaultArrayInfo{LowerBound: 3, UpperBound: 3, Range: true}, + }) + require.NoError(t, err) + + assert.Equal(t, uint32(112), scalar.IndexOffset, "three elements past the start") + assert.Equal(t, scalar.IndexOffset, listOfOne.IndexOffset, "the same element") + assert.Equal(t, uint32(4), scalar.TransferSizeInBytes()) + assert.Equal(t, uint32(4), listOfOne.TransferSizeInBytes()) + + assert.Empty(t, scalar.DecodeArrayInfo(), "decoded as a scalar") + assert.Len(t, listOfOne.DecodeArrayInfo(), 1, "decoded as a list of one") +} + +// A selection the device cannot satisfy is refused. Reading the wrong elements cannot be told +// apart from reading the right ones once the values come back, so this must not be approximated. +func TestSymbolicResolution_anOutOfBoundsSelectionIsRefused(t *testing.T) { + _, err := resolve(t, "MAIN.arr", []apiModel.ArrayInfo{ + &spiModel.DefaultArrayInfo{LowerBound: 8, UpperBound: 12, Range: true}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "[0..9]", "the error names what the PLC declares") +} + +// The direct counterpart: an address that names a memory location selects out of that location, +// so its selection is the whole of its shape. The request size was already multiplied by the +// count while the decoder was handed the scalar type's own (empty) shape, so three of these four +// elements were transferred and dropped. +func TestDirectResolution_aSelectionBecomesTheDecodedShape(t *testing.T) { + connection := connectionWithArraySymbol() + parsed, err := NewTagHandler().ParseTag("0x4020/100[0..3]:DINT") + require.NoError(t, err) + + tag, err := connection.directTagFor(context.Background(), parsed) + + require.NoError(t, err) + assert.Equal(t, uint32(16), tag.TransferSizeInBytes(), "four four-byte elements") + require.Len(t, tag.DecodeArrayInfo(), 1, "decoded as a list") + assert.Equal(t, uint32(4), tag.DecodeArrayInfo()[0].GetNumElements()) +} + +func TestDirectResolution_aScalarKeepsItsScalarShape(t *testing.T) { + connection := connectionWithArraySymbol() + parsed, err := NewTagHandler().ParseTag("0x4020/100:DINT") + require.NoError(t, err) + + tag, err := connection.directTagFor(context.Background(), parsed) + + require.NoError(t, err) + assert.Equal(t, uint32(4), tag.TransferSizeInBytes()) + assert.Empty(t, tag.DecodeArrayInfo()) +} + +// A second symbol, MAIN.grid, declared as ARRAY [0..9,0..4] OF DINT at group 0x4020, offset 500: +// ten rows of five four-byte elements, laid out row-major, so a row is 20 bytes. +func connectionWithGridSymbol() *Connection { + connection := connectionWithArraySymbol() + grid := driverModel.NewAdsDataTypeTableEntryBuilder(). + WithSize(200). + WithMainName("ARRAY [0..9,0..4] OF DINT"). + WithSecondaryName("ARRAY [0..9,0..4] OF DINT"). + WithArrayDimensions(2). + WithArrayInfo( + driverModel.NewAdsDataTypeArrayInfo(0, 10), + driverModel.NewAdsDataTypeArrayInfo(0, 5), + ). + MustBuild() + connection.driverContext.dataTypeTable["ARRAY [0..9,0..4] OF DINT"] = grid + connection.driverContext.symbolTable["MAIN.grid"] = driverModel.NewAdsSymbolTableEntryBuilder(). + WithGroup(0x4020). + WithOffset(500). + WithSize(200). + WithName("MAIN.grid"). + WithDataTypeName("ARRAY [0..9,0..4] OF DINT"). + MustBuild() + return connection +} + +func resolveGrid(t *testing.T, selection ...apiModel.ArrayInfo) (*model.DirectPlcTag, error) { + t.Helper() + return connectionWithGridSymbol().resolveSymbolicTag(context.Background(), + model.SymbolicPlcTag{SymbolicAddress: "MAIN.grid", PlcTag: model.PlcTag{ArrayInfo: selection}}) +} + +// One element of a two-dimensional array: row 3, column 2, which row-major puts at +// 500 + 3*20 + 2*4. +func TestGridSelection_oneElement(t *testing.T) { + tag, err := resolveGrid(t, + &spiModel.DefaultArrayInfo{LowerBound: 3, UpperBound: 3}, + &spiModel.DefaultArrayInfo{LowerBound: 2, UpperBound: 2}) + + require.NoError(t, err) + assert.Equal(t, uint32(568), tag.IndexOffset) + assert.Equal(t, uint32(4), tag.TransferSizeInBytes()) + assert.Empty(t, tag.DecodeArrayInfo(), "two bare indices name one element, which is a scalar") +} + +// Part of one row is contiguous: row 3, columns 1..3. +func TestGridSelection_partOfOneRow(t *testing.T) { + tag, err := resolveGrid(t, + &spiModel.DefaultArrayInfo{LowerBound: 3, UpperBound: 3}, + &spiModel.DefaultArrayInfo{LowerBound: 1, UpperBound: 3, Range: true}) + + require.NoError(t, err) + assert.Equal(t, uint32(564), tag.IndexOffset, "row 3, column 1") + assert.Equal(t, uint32(12), tag.TransferSizeInBytes(), "three elements") + require.Len(t, tag.DecodeArrayInfo(), 1, "the bare row index collapses; the range remains") + assert.Equal(t, uint32(3), tag.DecodeArrayInfo()[0].GetNumElements()) +} + +// Whole rows are contiguous too, and stay two-dimensional: rows 1..2, every column. No address +// can ask for this - the parser allows a range only in the last dimension - so this pins the +// resolver itself, which is what a tag built in code reaches. +func TestGridSelection_wholeRows(t *testing.T) { + tag, err := resolveGrid(t, + &spiModel.DefaultArrayInfo{LowerBound: 1, UpperBound: 2, Range: true}, + &spiModel.DefaultArrayInfo{LowerBound: 0, UpperBound: 4, Range: true}) + + require.NoError(t, err) + assert.Equal(t, uint32(520), tag.IndexOffset, "the start of row 1") + assert.Equal(t, uint32(40), tag.TransferSizeInBytes(), "two rows of five") + require.Len(t, tag.DecodeArrayInfo(), 2, "two rows of five, not a flat ten") + assert.Equal(t, uint32(2), tag.DecodeArrayInfo()[0].GetNumElements()) + assert.Equal(t, uint32(5), tag.DecodeArrayInfo()[1].GetNumElements()) +} + +// The case the contiguity rule exists for: part of every row is ten separate runs, and the +// contiguous block a single read returns is not what was asked for. The address parser refuses a +// range before the last dimension for the same reason, so this is the resolver standing behind +// that guarantee for a tag built in code. +func TestGridSelection_partOfEveryRowIsRefused(t *testing.T) { + _, err := resolveGrid(t, + &spiModel.DefaultArrayInfo{LowerBound: 0, UpperBound: 9, Range: true}, + &spiModel.DefaultArrayInfo{LowerBound: 1, UpperBound: 3, Range: true}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "contiguous") +} + +// Naming some of the dimensions is refused: what the unnamed ones select would be a guess. +func TestGridSelection_aPartialAddressIsRefused(t *testing.T) { + _, err := resolveGrid(t, &spiModel.DefaultArrayInfo{LowerBound: 3, UpperBound: 3}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "every dimension") +} + +// Bounds are held per dimension, not just on the first. +func TestGridSelection_anOutOfBoundsColumnIsRefused(t *testing.T) { + _, err := resolveGrid(t, + &spiModel.DefaultArrayInfo{LowerBound: 3, UpperBound: 3}, + &spiModel.DefaultArrayInfo{LowerBound: 3, UpperBound: 7, Range: true}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "[0..4]") +} diff --git a/plc4go/internal/ads/TagHandler.go b/plc4go/internal/ads/TagHandler.go index 8ef482b5314..cd21a736e17 100644 --- a/plc4go/internal/ads/TagHandler.go +++ b/plc4go/internal/ads/TagHandler.go @@ -23,11 +23,11 @@ import ( "fmt" "regexp" "strconv" - "strings" "github.com/apache/plc4x/plc4go/internal/ads/model" apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" apiValues "github.com/apache/plc4x/plc4go/pkg/api/values" + readWriteModel "github.com/apache/plc4x/plc4go/protocols/ads/readwrite/model" "github.com/apache/plc4x/plc4go/spi/errors" spiModel "github.com/apache/plc4x/plc4go/spi/model" "github.com/apache/plc4x/plc4go/spi/utils" @@ -37,27 +37,24 @@ type TagHandler struct { directAdsStringTag *regexp.Regexp directAdsTag *regexp.Regexp symbolicAdsTag *regexp.Regexp - arrayInfoSegment *regexp.Regexp driverContext *DriverContext } // NewTagHandler this constructor creates a version of the TagHandler that's detached from a connection and can't provide context-sensitive feedback. func NewTagHandler() TagHandler { return TagHandler{ - directAdsStringTag: regexp.MustCompile(`^((0[xX](?P[0-9a-fA-F]+))|(?P\d+))/((0[xX](?P[0-9a-fA-F]+))|(?P\d+)):(?PSTRING|WSTRING)\((?P\d{1,3})\)(?P((\[(\d+)])|(\[(\d+)\.\.(\d+)])|(\[(\d+):(\d+)]))*)`), - directAdsTag: regexp.MustCompile(`^((0[xX](?P[0-9a-fA-F]+))|(?P\d+))/((0[xX](?P[0-9a-fA-F]+))|(?P\d+)):(?P\w+)(?P((\[(\d+)])|(\[(\d+)\.\.(\d+)])|(\[(\d+):(\d+)]))*)`), - symbolicAdsTag: regexp.MustCompile(`^(?P[^\[]+)(?P((\[(\d+)])|(\[(\d+)\.\.(\d+)])|(\[(\d+):(\d+)]))*)`), - arrayInfoSegment: regexp.MustCompile(`((^(?P\d+)$)|(^((?P\d+)\.\.(?P\d+))$)|(^((?P\d+):(?P\d+)))$)`), + directAdsStringTag: regexp.MustCompile(`^((0[xX](?P[0-9a-fA-F]+))|(?P\d+))/((0[xX](?P[0-9a-fA-F]+))|(?P\d+))` + spiModel.ArrayGroupPattern + `:(?PSTRING|WSTRING)\((?P\d{1,3})\)$`), + directAdsTag: regexp.MustCompile(`^((0[xX](?P[0-9a-fA-F]+))|(?P\d+))/((0[xX](?P[0-9a-fA-F]+))|(?P\d+))` + spiModel.ArrayGroupPattern + `:(?P\w+)$`), + symbolicAdsTag: regexp.MustCompile(`^(?P[^\[]+)` + spiModel.ArrayGroupPattern + `$`), } } // NewTagHandlerWithDriverContext this constructor creates a version of the TagHandler that is connected to a connection and can provide context-sensitive feedback. func NewTagHandlerWithDriverContext(driverContext *DriverContext) TagHandler { return TagHandler{ - directAdsStringTag: regexp.MustCompile(`^((0[xX](?P[0-9a-fA-F]+))|(?P\d+))/((0[xX](?P[0-9a-fA-F]+))|(?P\d+)):(?PSTRING|WSTRING)\((?P\d{1,3})\)(?P((\[(\d+)])|(\[(\d+)\.\.(\d+)])|(\[(\d+):(\d+)]))*)`), - directAdsTag: regexp.MustCompile(`^((0[xX](?P[0-9a-fA-F]+))|(?P\d+))/((0[xX](?P[0-9a-fA-F]+))|(?P\d+)):(?P\w+)(?P((\[(\d+)])|(\[(\d+)\.\.(\d+)])|(\[(\d+):(\d+)]))*)`), - symbolicAdsTag: regexp.MustCompile(`^(?P[^\[]+)(?P((\[(\d+)])|(\[(\d+)\.\.(\d+)])|(\[(\d+):(\d+)]))*)`), - arrayInfoSegment: regexp.MustCompile(`((^(?P\d+)$)|(^((?P\d+)\.\.(?P\d+))$)|(^((?P\d+):(?P\d+)))$)`), + directAdsStringTag: regexp.MustCompile(`^((0[xX](?P[0-9a-fA-F]+))|(?P\d+))/((0[xX](?P[0-9a-fA-F]+))|(?P\d+))` + spiModel.ArrayGroupPattern + `:(?PSTRING|WSTRING)\((?P\d{1,3})\)$`), + directAdsTag: regexp.MustCompile(`^((0[xX](?P[0-9a-fA-F]+))|(?P\d+))/((0[xX](?P[0-9a-fA-F]+))|(?P\d+))` + spiModel.ArrayGroupPattern + `:(?P\w+)$`), + symbolicAdsTag: regexp.MustCompile(`^(?P[^\[]+)` + spiModel.ArrayGroupPattern + `$`), driverContext: driverContext, } } @@ -114,55 +111,15 @@ func (m TagHandler) ParseTag(query string) (apiModel.PlcTag, error) { } stringLength = int32(tmpStringLength) - if match["arrayInfo"] != "" { - arrayInfoString := match["arrayInfo"] - - arrayInfo = []apiModel.ArrayInfo{} - - // Cut off the starting and ending bracket - arrayInfoString = arrayInfoString[1:(len(arrayInfoString) - 1)] - // Split the remaining string into separate segments. - arrayInfoSegments := strings.SplitSeq(arrayInfoString, "][") - for currentSegment := range arrayInfoSegments { - if match := utils.GetSubgroupMatches(m.arrayInfoSegment, currentSegment); match != nil { - if match["startElement"] != "" && match["endElement"] != "" { - startElement, err := m.getUint32Value(match["startElement"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - endElement, err := m.getUint32Value(match["endElement"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - arrayInfo = append(arrayInfo, &spiModel.DefaultArrayInfo{ - LowerBound: startElement, - UpperBound: endElement, - }) - } else if match["startElement2"] != "" && match["numElements2"] != "" { - startElement, err := m.getUint32Value(match["startElement2"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - numElements, err := m.getUint32Value(match["numElements2"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - arrayInfo = append(arrayInfo, &spiModel.DefaultArrayInfo{ - LowerBound: startElement, - UpperBound: startElement + numElements, - }) - } else if match["numElements"] != "" { - numElements, err := m.getUint32Value(match["numElements"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - arrayInfo = append(arrayInfo, &spiModel.DefaultArrayInfo{ - LowerBound: 0, - UpperBound: numElements, - }) - } - } - } + // The selection goes through the shared parser, so plc4go accepts exactly what plc4j + // accepts. A direct address names a memory location, so it carries one dimension. + arrayInfo, err = spiModel.ParseArrayExpression(match["array"], query, spiModel.SingleDimension) + if err != nil { + return nil, err + } + indexOffset, err = applySelectionOffset(indexOffset, arrayInfo, match["adsDataType"], stringLength, query) + if err != nil { + return nil, err } return model.NewDirectAdsPlcTag(indexGroup, indexOffset, plcValueType, stringLength, arrayInfo) @@ -208,118 +165,110 @@ func (m TagHandler) ParseTag(query string) (apiModel.PlcTag, error) { return nil, fmt.Errorf("invalid ads data type") } - var arrayInfo []apiModel.ArrayInfo + // The selection goes through the shared parser, so plc4go accepts exactly what plc4j + // accepts. A direct address names a memory location, so it carries one dimension. + arrayInfo, err := spiModel.ParseArrayExpression(match["array"], query, spiModel.SingleDimension) + if err != nil { + return nil, err + } + indexOffset, err = applySelectionOffset(indexOffset, arrayInfo, adsDataTypeName, 0, query) + if err != nil { + return nil, err + } - if match["arrayInfo"] != "" { - arrayInfoString := match["arrayInfo"] + return model.NewDirectAdsPlcTag(indexGroup, indexOffset, plcValueType, model.NONE, arrayInfo) + } else if match := utils.GetSubgroupMatches(m.symbolicAdsTag, query); match != nil { + // A symbolic address is anything that is not a direct one, so an address that looks + // direct but does not parse would otherwise be accepted here as a symbol name of its + // own - "1234/5678:BOOL[42]", written before the notation moved, would silently become + // a symbol lookup rather than an error. Report it instead, naming the address to write. + if looksLikeADirectAddress(query) { + return nil, spiModel.InvalidAddressError(query, + "{indexGroup}/{indexOffset}[selection]:{TYPE} - for example 0x4020/0[0..3]:DINT") + } - arrayInfo = []apiModel.ArrayInfo{} + // The selection goes through the shared parser, so plc4go accepts exactly what plc4j + // accepts. Only the last dimension may span more than one element: a range before it + // would ask for a member of several elements at once, which is not one contiguous read. + arrayInfo, err := spiModel.ParseArrayExpression(match["array"], query, + spiModel.Unconstrained.WithOnlyTrailingDimensionMayBeRange(true)) + if err != nil { + return nil, err + } - // Cut off the starting and ending bracket - arrayInfoString = arrayInfoString[1:(len(arrayInfoString) - 1)] - // Split the remaining string into separate segments. - arrayInfoSegments := strings.SplitSeq(arrayInfoString, "][") - for currentSegment := range arrayInfoSegments { - if match := utils.GetSubgroupMatches(m.arrayInfoSegment, currentSegment); match != nil { - if match["startElement"] != "" && match["endElement"] != "" { - startElement, err := m.getUint32Value(match["startElement"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - endElement, err := m.getUint32Value(match["endElement"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - arrayInfo = append(arrayInfo, &spiModel.DefaultArrayInfo{ - LowerBound: startElement, - UpperBound: endElement, - }) - } else if match["startElement2"] != "" && match["numElements2"] != "" { - startElement, err := m.getUint32Value(match["startElement2"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - numElements, err := m.getUint32Value(match["numElements2"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - arrayInfo = append(arrayInfo, &spiModel.DefaultArrayInfo{ - LowerBound: startElement, - UpperBound: startElement + numElements, - }) - } else if match["numElements"] != "" { - numElements, err := m.getUint32Value(match["numElements"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - arrayInfo = append(arrayInfo, &spiModel.DefaultArrayInfo{ - LowerBound: 0, - UpperBound: numElements, - }) - } - } + return model.NewAdsSymbolicPlcTag(match["symbolicAddress"], arrayInfo) + } else { + // The start-and-count form was a plc4go extension with no counterpart in plc4j, so it + // is gone. Name the range that selects the same elements rather than reporting only + // that nothing matched. + if match := startAndCountForm.FindStringSubmatch(query); match != nil { + start, _ := strconv.Atoi(match[2]) + count, _ := strconv.Atoi(match[3]) + if count > 0 { + return nil, errors.Errorf("invalid address '%s': the start-and-count form '[a:b]' "+ + "is no longer supported, so this address is now written '%s[%d..%d]'", + query, match[1], start, start+count-1) } } + return nil, spiModel.InvalidAddressError(query, + "{symbol}[selection] or {indexGroup}/{indexOffset}[selection]:{TYPE} - "+ + "for example MAIN.g_arr[0..3]") + } +} - return model.NewDirectAdsPlcTag(indexGroup, indexOffset, plcValueType, model.NONE, arrayInfo) - } else if match := utils.GetSubgroupMatches(m.symbolicAdsTag, query); match != nil { - var arrayInfo []apiModel.ArrayInfo +// looksLikeADirectAddress reports whether an address has the shape of a direct one - an index +// group and offset separated by a slash - regardless of whether it parses. A symbolic address +// never contains a slash, so this cannot mistake one for the other. +// startAndCountForm matches the removed "[a:b]" spelling - a start and a count - so a rejection +// can name the range that selects the same elements. +var startAndCountForm = regexp.MustCompile(`^(.*)\[(\d+):(\d+)]$`) - if match["arrayInfo"] != "" { - arrayInfoString := match["arrayInfo"] +var directAddressShape = regexp.MustCompile(`^(?:0[xX][0-9a-fA-F]+|\d+)/(?:0[xX][0-9a-fA-F]+|\d+):`) - arrayInfo = []apiModel.ArrayInfo{} +func looksLikeADirectAddress(query string) bool { + return directAddressShape.MatchString(query) +} - // Cut off the starting and ending bracket - arrayInfoString = arrayInfoString[1:(len(arrayInfoString) - 1)] - // Split the remaining string into separate segments. - arrayInfoSegments := strings.SplitSeq(arrayInfoString, "][") - for currentSegment := range arrayInfoSegments { - if match := utils.GetSubgroupMatches(m.arrayInfoSegment, currentSegment); match != nil { - if match["startElement"] != "" && match["endElement"] != "" { - startElement, err := m.getUint32Value(match["startElement"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - endElement, err := m.getUint32Value(match["endElement"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - arrayInfo = append(arrayInfo, &spiModel.DefaultArrayInfo{ - LowerBound: startElement, - UpperBound: endElement, - }) - } else if match["startElement2"] != "" && match["numElements2"] != "" { - startElement, err := m.getUint32Value(match["startElement2"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - numElements, err := m.getUint32Value(match["numElements2"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - arrayInfo = append(arrayInfo, &spiModel.DefaultArrayInfo{ - LowerBound: startElement, - UpperBound: startElement + numElements, - }) - } else if match["numElements"] != "" { - numElements, err := m.getUint32Value(match["numElements"]) - if err != nil { - return nil, fmt.Errorf("error parsing array info: %s, got error: %v", currentSegment, err) - } - arrayInfo = append(arrayInfo, &spiModel.DefaultArrayInfo{ - LowerBound: 0, - UpperBound: numElements, - }) - } - } - } - } +// applySelectionOffset moves a direct address to the element the selection starts at. +// +// An ADS index offset is a *byte* offset while a selection counts elements, so the two have to be +// reconciled - and until now they were not reconciled at all here: the selection was parsed, put +// on the tag, and the offset left untouched, so every element of an array resolved to the first. +// +// The device's data-type table is not available while an address is being parsed, so only the +// types ADS defines itself can be measured. A selection on anything else is refused rather than +// applied at a guessed offset; an address without a selection needs no offset and is unaffected. +func applySelectionOffset(indexOffset uint32, arrayInfo []apiModel.ArrayInfo, + adsDataTypeName string, stringLength int32, query string) (uint32, error) { + if len(arrayInfo) == 0 { + return indexOffset, nil + } + elements := arrayInfo[0].GetLowerBound() - arrayInfo[0].GetBase() + if elements == 0 { + return indexOffset, nil + } + bytesPerElement, err := bytesPerElement(adsDataTypeName, stringLength, query) + if err != nil { + return 0, err + } + return indexOffset + (elements * bytesPerElement), nil +} - return model.NewAdsSymbolicPlcTag(match["symbolicAddress"], arrayInfo) - } else { - return nil, errors.Errorf("Invalid address format for address '%s'", query) +// bytesPerElement is the storage one element of the named type occupies. A string occupies its +// declared length plus the terminator, doubled for WSTRING - the same size the reader asks for. +func bytesPerElement(adsDataTypeName string, stringLength int32, query string) (uint32, error) { + switch adsDataTypeName { + case "STRING": + return uint32(stringLength + 1), nil + case "WSTRING": + return uint32(stringLength+1) * 2, nil + } + if dataType, ok := readWriteModel.AdsDataTypeByName(adsDataTypeName); ok { + return uint32(dataType.NumBytes()), nil } + return 0, errors.Errorf("Cannot place a selection in '%s': the size of type '%s' is only known "+ + "to the device, so the element's offset cannot be computed here. Address the element directly instead.", + query, adsDataTypeName) } func (m TagHandler) ParseQuery(query string) (apiModel.PlcQuery, error) { diff --git a/plc4go/internal/ads/TagHandler_test.go b/plc4go/internal/ads/TagHandler_test.go index b081bff771b..aa9fb9b442f 100644 --- a/plc4go/internal/ads/TagHandler_test.go +++ b/plc4go/internal/ads/TagHandler_test.go @@ -24,6 +24,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/apache/plc4x/plc4go/internal/ads/model" apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" @@ -109,14 +110,15 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "simple array direct numeric address", args: args{ - query: "1234/5678:BOOL[42]", + query: "1234/5678[0..41]:BOOL", }, want: model.DirectPlcTag{ PlcTag: model.PlcTag{ ArrayInfo: []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: 42, + UpperBound: 41, + Range: true, }, }, }, @@ -129,14 +131,15 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "simple array direct hex address", args: args{ - query: "0x04D2/0x162E:BOOL[42]", + query: "0x04D2/0x162E[0..41]:BOOL", }, want: model.DirectPlcTag{ PlcTag: model.PlcTag{ ArrayInfo: []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: 42, + UpperBound: 41, + Range: true, }, }, }, @@ -149,14 +152,15 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "simple array direct numeric string address", args: args{ - query: "1234/5678:STRING(80)[42]", + query: "1234/5678[0..41]:STRING(80)", }, want: model.DirectPlcTag{ PlcTag: model.PlcTag{ ArrayInfo: []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: 42, + UpperBound: 41, + Range: true, }, }, }, @@ -169,14 +173,15 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "simple array direct hex string address", args: args{ - query: "0x04D2/0x162E:WSTRING(80)[42]", + query: "0x04D2/0x162E[0..41]:WSTRING(80)", }, want: model.DirectPlcTag{ PlcTag: model.PlcTag{ ArrayInfo: []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: 42, + UpperBound: 41, + Range: true, }, }, }, @@ -189,14 +194,15 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "simple array symbolic address", args: args{ - query: "MAIN.testVariable[42]", + query: "MAIN.testVariable[0..41]", }, want: model.SymbolicPlcTag{ PlcTag: model.PlcTag{ ArrayInfo: []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: 42, + UpperBound: 41, + Range: true, }, }, }, @@ -207,7 +213,7 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "range array direct numeric address", args: args{ - query: "1234/5678:BOOL[23..42]", + query: "1234/5678[23..42]:BOOL", }, want: model.DirectPlcTag{ PlcTag: model.PlcTag{ @@ -215,11 +221,12 @@ func TestTagHandler_ParseQuery(t *testing.T) { &spiModel.DefaultArrayInfo{ LowerBound: 23, UpperBound: 42, + Range: true, }, }, }, IndexGroup: 1234, - IndexOffset: 5678, + IndexOffset: 5701, ValueType: apiValues.BOOL, StringLength: model.NONE, }, @@ -227,7 +234,7 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "range array direct hex address", args: args{ - query: "0x04D2/0x162E:BOOL[23..42]", + query: "0x04D2/0x162E[23..42]:BOOL", }, want: model.DirectPlcTag{ PlcTag: model.PlcTag{ @@ -235,11 +242,12 @@ func TestTagHandler_ParseQuery(t *testing.T) { &spiModel.DefaultArrayInfo{ LowerBound: 23, UpperBound: 42, + Range: true, }, }, }, IndexGroup: 1234, - IndexOffset: 5678, + IndexOffset: 5701, ValueType: apiValues.BOOL, StringLength: model.NONE, }, @@ -247,7 +255,7 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "range array direct numeric string address", args: args{ - query: "1234/5678:STRING(80)[23..42]", + query: "1234/5678[23..42]:STRING(80)", }, want: model.DirectPlcTag{ PlcTag: model.PlcTag{ @@ -255,11 +263,12 @@ func TestTagHandler_ParseQuery(t *testing.T) { &spiModel.DefaultArrayInfo{ LowerBound: 23, UpperBound: 42, + Range: true, }, }, }, IndexGroup: 1234, - IndexOffset: 5678, + IndexOffset: 7541, ValueType: apiValues.STRING, StringLength: 80, }, @@ -267,7 +276,7 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "range array direct hex string address", args: args{ - query: "0x04D2/0x162E:WSTRING(80)[23..42]", + query: "0x04D2/0x162E[23..42]:WSTRING(80)", }, want: model.DirectPlcTag{ PlcTag: model.PlcTag{ @@ -275,11 +284,12 @@ func TestTagHandler_ParseQuery(t *testing.T) { &spiModel.DefaultArrayInfo{ LowerBound: 23, UpperBound: 42, + Range: true, }, }, }, IndexGroup: 1234, - IndexOffset: 5678, + IndexOffset: 9404, ValueType: apiValues.WSTRING, StringLength: 80, }, @@ -295,6 +305,7 @@ func TestTagHandler_ParseQuery(t *testing.T) { &spiModel.DefaultArrayInfo{ LowerBound: 23, UpperBound: 42, + Range: true, }, }, }, @@ -305,19 +316,20 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "array with offset direct numeric address", args: args{ - query: "1234/5678:BOOL[23:42]", + query: "1234/5678[23..64]:BOOL", }, want: model.DirectPlcTag{ PlcTag: model.PlcTag{ ArrayInfo: []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 23, - UpperBound: 65, + UpperBound: 64, + Range: true, }, }, }, IndexGroup: 1234, - IndexOffset: 5678, + IndexOffset: 5701, ValueType: apiValues.BOOL, StringLength: model.NONE, }, @@ -325,19 +337,20 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "array with offset direct hex address", args: args{ - query: "0x04D2/0x162E:BOOL[23:42]", + query: "0x04D2/0x162E[23..64]:BOOL", }, want: model.DirectPlcTag{ PlcTag: model.PlcTag{ ArrayInfo: []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 23, - UpperBound: 65, + UpperBound: 64, + Range: true, }, }, }, IndexGroup: 1234, - IndexOffset: 5678, + IndexOffset: 5701, ValueType: apiValues.BOOL, StringLength: model.NONE, }, @@ -345,19 +358,20 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "array with offset direct numeric string address", args: args{ - query: "1234/5678:STRING(80)[23:42]", + query: "1234/5678[23..64]:STRING(80)", }, want: model.DirectPlcTag{ PlcTag: model.PlcTag{ ArrayInfo: []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 23, - UpperBound: 65, + UpperBound: 64, + Range: true, }, }, }, IndexGroup: 1234, - IndexOffset: 5678, + IndexOffset: 7541, ValueType: apiValues.STRING, StringLength: 80, }, @@ -365,19 +379,20 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "array with offset direct hex string address", args: args{ - query: "0x04D2/0x162E:WSTRING(80)[23:42]", + query: "0x04D2/0x162E[23..64]:WSTRING(80)", }, want: model.DirectPlcTag{ PlcTag: model.PlcTag{ ArrayInfo: []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 23, - UpperBound: 65, + UpperBound: 64, + Range: true, }, }, }, IndexGroup: 1234, - IndexOffset: 5678, + IndexOffset: 9404, ValueType: apiValues.WSTRING, StringLength: 80, }, @@ -385,14 +400,15 @@ func TestTagHandler_ParseQuery(t *testing.T) { { name: "array with offset symbolic address", args: args{ - query: "MAIN.testVariable[23:42]", + query: "MAIN.testVariable[23..64]", }, want: model.SymbolicPlcTag{ PlcTag: model.PlcTag{ ArrayInfo: []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 23, - UpperBound: 65, + UpperBound: 64, + Range: true, }, }, }, @@ -414,3 +430,48 @@ func TestTagHandler_ParseQuery(t *testing.T) { }) } } + +// Addresses written before the array notation was unified must not be accepted. +// +// Go's ADS driver diverged from plc4j in two ways: [n] meant a count of n elements rather than +// the element at index n, and [a:b] meant a start and a count. Both are gone, so a Go address +// means what the same Java address means. +func TestTagHandler_LegacyAddressesAreRejected(t *testing.T) { + handler := NewTagHandler() + + t.Run("the selection may no longer follow the type", func(t *testing.T) { + // This used to parse as a direct tag. Without a guard it would fall through to the + // symbolic pattern and silently become a symbol lookup. + _, err := handler.ParseTag("1234/5678:BOOL[42]") + require.Error(t, err) + assert.Contains(t, err.Error(), "0x4020/0[0..3]:DINT", "the message must name the form") + }) + + t.Run("the start-and-count form is gone", func(t *testing.T) { + _, err := handler.ParseTag("MAIN.testVariable[23:42]") + require.Error(t, err) + }) + + t.Run("the replacement forms parse", func(t *testing.T) { + _, err := handler.ParseTag("1234/5678[0..41]:BOOL") + require.NoError(t, err) + _, err = handler.ParseTag("MAIN.testVariable[23..64]") + require.NoError(t, err) + }) +} + +// A bare index selects one element and yields a scalar; a range yields an array even when it +// spans one element. This is what a consumer reads GetArrayInfo to decide. +func TestTagHandler_ABareIndexIsAScalar(t *testing.T) { + handler := NewTagHandler() + + index, err := handler.ParseTag("MAIN.testVariable[4]") + require.NoError(t, err) + assert.Empty(t, index.GetArrayInfo(), "one element is a scalar, so there is no dimension to report") + assert.Equal(t, "MAIN.testVariable[4]", index.GetAddressString(), "and the index it selects is still 4, not a count of four") + + arrayRange, err := handler.ParseTag("MAIN.testVariable[4..4]") + require.NoError(t, err) + assert.True(t, arrayRange.GetArrayInfo()[0].IsRange()) + assert.Equal(t, uint32(1), arrayRange.GetArrayInfo()[0].GetSize()) +} diff --git a/plc4go/internal/ads/Writer.go b/plc4go/internal/ads/Writer.go index e9966ac4b17..390f9f1f663 100644 --- a/plc4go/internal/ads/Writer.go +++ b/plc4go/internal/ads/Writer.go @@ -24,7 +24,6 @@ import ( "encoding/binary" "fmt" "runtime/debug" - "strings" "github.com/apache/plc4x/plc4go/internal/ads/model" apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" @@ -137,22 +136,15 @@ func (m *Connection) multiWrite(ctx context.Context, writeRequest apiModel.PlcWr return } - // Size of one element. - size := directAdsTag.DataType.GetSize() - - // Calculate how many elements in total we'll be reading. - arraySize := uint32(1) - if len(tag.GetArrayInfo()) > 0 { - for _, arrayInfo := range tag.GetArrayInfo() { - arraySize = arraySize * arrayInfo.GetSize() - } - } + // How many bytes this tag transfers - the whole of what its type declares, or just the + // part the address selected out of it, which the resolved tag knows. + size := directAdsTag.TransferSizeInBytes() // Status code + payload size expectedResponseDataSize += 4 requestItems = append(requestItems, driverModel.NewAdsMultiRequestItemWrite( - directAdsTag.IndexGroup, directAdsTag.IndexOffset, size*arraySize)) + directAdsTag.IndexGroup, directAdsTag.IndexOffset, size)) } response, err := m.ExecuteAdsReadWriteRequest(ctx, @@ -204,10 +196,9 @@ func (m *Connection) serializePlcValue(dataType driverModel.AdsDataTypeTableEntr return fmt.Errorf("expecting exactly %d items in the list", len(plcValues)) } - arrayItemTypeName := dataType.GetSecondaryName()[strings.Index(dataType.GetSecondaryName(), " OF ")+4:] - arrayItemType, ok := m.driverContext.dataTypeTable[arrayItemTypeName] - if !ok { - return fmt.Errorf("couldn't resolve array item type %s", arrayItemTypeName) + arrayItemType, err := m.arrayItemTypeFor(dataType) + if err != nil { + return err } for _, plcValue := range plcValues { diff --git a/plc4go/internal/ads/model/Tag.go b/plc4go/internal/ads/model/Tag.go index 0d8d7f3d7d0..61617c7e6fa 100644 --- a/plc4go/internal/ads/model/Tag.go +++ b/plc4go/internal/ads/model/Tag.go @@ -30,6 +30,7 @@ import ( apiValues "github.com/apache/plc4x/plc4go/pkg/api/values" readWriteModel "github.com/apache/plc4x/plc4go/protocols/ads/readwrite/model" "github.com/apache/plc4x/plc4go/spi/errors" + spiModel "github.com/apache/plc4x/plc4go/spi/model" "github.com/apache/plc4x/plc4go/spi/utils" ) @@ -60,6 +61,46 @@ type DirectPlcTag struct { ValueType apiValues.PlcValueType StringLength int32 DataType readWriteModel.AdsDataTypeTableEntry + + // SelectedArrayInfo is the shape to transfer and decode when the address selected part of a + // location, rather than the whole of what DataType declares. It is nil when the address + // selected nothing, and the declared shape governs as before. + // + // Without it a selection was parsed, rendered and then ignored: a symbolic MAIN.arr[1..4] + // read the whole array from its original offset, and a direct 0x4020/0[0..3]:DINT asked the + // device for four elements and decoded one. Both returned a well-formed value for a location + // nobody asked about, which is the failure that cannot be seen from the outside. + SelectedArrayInfo []readWriteModel.AdsDataTypeArrayInfo + + // SelectedSizeInBytes is how many bytes the selection spans; it is meaningless, and zero, + // when SelectedArrayInfo is nil. + SelectedSizeInBytes uint32 +} + +// TransferSizeInBytes is how many bytes to ask the device for. +// +// A non-zero SelectedSizeInBytes is what marks a narrowed location, rather than a non-empty +// SelectedArrayInfo: selecting one element of an array narrows the transfer to that element while +// leaving no shape at all, because a bare index is a scalar. +func (m DirectPlcTag) TransferSizeInBytes() uint32 { + if m.SelectedSizeInBytes > 0 { + return m.SelectedSizeInBytes + } + if m.DataType == nil { + return 0 + } + return m.DataType.GetSize() +} + +// DecodeArrayInfo is the shape to decode into, which the address may have narrowed. +func (m DirectPlcTag) DecodeArrayInfo() []readWriteModel.AdsDataTypeArrayInfo { + if m.SelectedSizeInBytes > 0 { + return m.SelectedArrayInfo + } + if m.DataType == nil { + return nil + } + return m.DataType.GetArrayInfo() } func NewDirectAdsPlcTag(indexGroup uint32, indexOffset uint32, valueType apiValues.PlcValueType, stringLength int32, arrayInfo []apiModel.ArrayInfo) (apiModel.PlcTag, error) { @@ -80,15 +121,14 @@ func CastToDirectAdsTagFromPlcTag(plcTag apiModel.PlcTag) (DirectPlcTag, error) } func (m DirectPlcTag) GetAddressString() string { - address := fmt.Sprintf("0x%d/%d:%s", m.IndexGroup, m.IndexOffset, m.ValueType.String()) + // The selection sits before the type, and the group is rendered in the hex the "0x" claims - + // "0x%d" printed the decimal digits under a hex prefix, so 16416 came back as 0x16416, an + // address that parses to a different index group than the one it was rendered from. + address := fmt.Sprintf("0x%X/%d%s:%s", m.IndexGroup, m.IndexOffset, + spiModel.RenderArrayExpression(m.ArrayInfo), m.ValueType.String()) if m.ValueType == apiValues.STRING || m.ValueType == apiValues.WSTRING { address = address + "(" + strconv.Itoa(int(m.StringLength)) + ")" } - if len(m.ArrayInfo) > 0 { - for _, ai := range m.ArrayInfo { - address = address + "[" + strconv.Itoa(int(ai.GetLowerBound())) + ".." + strconv.Itoa(int(ai.GetUpperBound())) + "]" - } - } return address } @@ -96,10 +136,23 @@ func (m DirectPlcTag) GetValueType() apiValues.PlcValueType { return m.ValueType } -func (m DirectPlcTag) GetArrayInfo() []apiModel.ArrayInfo { +// shapeOf reports the shape of the value the caller receives: empty for a scalar, one entry per +// dimension for an array. A bare index selects one element and so reports empty; a range reports +// its dimensions even when it spans one element. plc4j decides this by re-reading the address +// string; the dimensions here already carry the distinction, so read it from them. +func shapeOf(arrayInfo []apiModel.ArrayInfo) []apiModel.ArrayInfo { + for _, dimension := range arrayInfo { + if dimension.IsRange() { + return arrayInfo + } + } return []apiModel.ArrayInfo{} } +func (m DirectPlcTag) GetArrayInfo() []apiModel.ArrayInfo { + return shapeOf(m.ArrayInfo) +} + func (m DirectPlcTag) Serialize() ([]byte, error) { wb := utils.NewWriteBufferByteBased(utils.WithByteOrderForByteBasedBuffer(binary.BigEndian)) if err := m.SerializeWithWriteBuffer(context.Background(), wb); err != nil { @@ -189,7 +242,7 @@ func CastToSymbolicPlcTagFromPlcTag(plcTag apiModel.PlcTag) (SymbolicPlcTag, err } func (m SymbolicPlcTag) GetAddressString() string { - return m.SymbolicAddress + return m.SymbolicAddress + spiModel.RenderArrayExpression(m.ArrayInfo) } func (m SymbolicPlcTag) GetValueType() apiValues.PlcValueType { @@ -197,7 +250,7 @@ func (m SymbolicPlcTag) GetValueType() apiValues.PlcValueType { } func (m SymbolicPlcTag) GetArrayInfo() []apiModel.ArrayInfo { - return []apiModel.ArrayInfo{} + return shapeOf(m.ArrayInfo) } func (m SymbolicPlcTag) Serialize() ([]byte, error) { diff --git a/plc4go/internal/bacnetip/Reader.go b/plc4go/internal/bacnetip/Reader.go index bcc434db8b2..467478ae9b6 100644 --- a/plc4go/internal/bacnetip/Reader.go +++ b/plc4go/internal/bacnetip/Reader.go @@ -82,7 +82,9 @@ func (m *Reader) Read(ctx context.Context, readRequest apiModel.PlcReadRequest) var serviceRequest readWriteModel.BACnetConfirmedServiceRequest quantity := uint32(1) if len(readRequest.GetTag(readRequest.GetTagNames()[0]).GetArrayInfo()) > 0 { - quantity = readRequest.GetTag(readRequest.GetTagNames()[0]).GetArrayInfo()[0].GetUpperBound() - readRequest.GetTag(readRequest.GetTagNames()[0]).GetArrayInfo()[0].GetLowerBound() + // GetSize, not upper minus lower: both bounds are inclusive, so subtracting them + // counts one element short of what the caller asked for. + quantity = readRequest.GetTag(readRequest.GetTagNames()[0]).GetArrayInfo()[0].GetSize() } if isMultiRequest := len(readRequest.GetTagNames()) > 1 || quantity > 1; !isMultiRequest { // Single request diff --git a/plc4go/internal/bacnetip/Tag.go b/plc4go/internal/bacnetip/Tag.go index e4f1666cddd..53ac7921efa 100644 --- a/plc4go/internal/bacnetip/Tag.go +++ b/plc4go/internal/bacnetip/Tag.go @@ -116,7 +116,7 @@ func (p property) String() string { result += fmt.Sprint(*p.PropertyIdentifierProprietary) } if p.ArrayIndex != nil { - result += fmt.Sprintf(":[%d]", p.ArrayIndex) + result += fmt.Sprintf(":[%d]", *p.ArrayIndex) } if p.WritePriority != nil { result += fmt.Sprintf(":{%d}", *p.WritePriority) @@ -124,19 +124,56 @@ func (p property) String() string { return result } +// addressString spells one property the way the tag handler parses it back: +// {PROPERTY}[index]{writePriority}. property.String is a display form, with separating colons +// the address syntax has no place for, and it is what the tag serializes as - so the two are +// kept apart rather than one being bent into the other. +func (p property) addressString() string { + var result string + if p.PropertyIdentifier != nil { + result += fmt.Sprint(*p.PropertyIdentifier) + } else { + result += fmt.Sprint(*p.PropertyIdentifierProprietary) + } + if p.ArrayIndex != nil { + result += fmt.Sprintf("[%d]", *p.ArrayIndex) + } + if p.WritePriority != nil { + result += fmt.Sprintf("{%d}", *p.WritePriority) + } + return result +} + +// addressString spells the object the way the tag handler parses it back: {TYPE},{instance}. +func (o objectId) addressString() string { + var result string + if o.ObjectIdType != nil { + result += fmt.Sprint(*o.ObjectIdType) + } else { + result += fmt.Sprint(*o.ObjectIdTypeProprietary) + } + return result + fmt.Sprintf(",%d", o.ObjectIdInstance) +} + +// GetAddressString spells the tag the way the tag handler parses it back. It used to render the +// display form instead, which parses back as nothing at all: the object separator came out as +// ':' where the syntax wants ',', each property carried a leading ':' the syntax has no place +// for, and the array index printed the address of the pointer holding it rather than the index. func (m plcTag) GetAddressString() string { var properties []string for _, p := range m.Properties { - properties = append(properties, fmt.Sprint(p)) + properties = append(properties, p.addressString()) } - propertiesString := strings.Join(properties, "&") - return fmt.Sprintf("%v/%s", m.ObjectId, propertiesString) + return fmt.Sprintf("%s/%s", m.ObjectId.addressString(), strings.Join(properties, "&")) } func (m plcTag) GetValueType() apiValues.PlcValueType { return apiValues.Struct } +// GetArrayInfo reports the shape of the value the caller receives. A BACnet address selects one +// property, and the bracket it may carry is an index into that property's array - one element, +// which is a scalar. There is no form here that selects several, so this is always empty. func (m plcTag) GetArrayInfo() []apiModel.ArrayInfo { return []apiModel.ArrayInfo{} } diff --git a/plc4go/internal/bacnetip/TagHandler_test.go b/plc4go/internal/bacnetip/TagHandler_test.go index 8bf1effb173..2f24dcda366 100644 --- a/plc4go/internal/bacnetip/TagHandler_test.go +++ b/plc4go/internal/bacnetip/TagHandler_test.go @@ -154,3 +154,39 @@ func TestTagHandler_ParseQueryNotSupported(t *testing.T) { _, err := h.ParseQuery("anything") assert.Error(t, err) } + +// A rendered address must parse back to the same tag. It did not: the object separator came out +// as ':' where the syntax wants ',', each property carried a leading ':' the syntax has no place +// for, and the array index printed the address of the pointer holding it - so an address like +// "ANALOG_OUTPUT:5/PRESENT_VALUE:[92527314467080]:{16}" came back out and parsed as nothing. +func TestTagHandler_AddressStringRoundTrips(t *testing.T) { + h := NewTagHandler() + + for _, address := range []string{ + "ANALOG_VALUE,2/PRESENT_VALUE", + "ANALOG_VALUE,2/PRESENT_VALUE[3]", + "ANALOG_OUTPUT,5/PRESENT_VALUE[2]{16}", + "ANALOG_OUTPUT,1/PRESENT_VALUE{8}", + "ANALOG_VALUE,2/PRESENT_VALUE[1]&DESCRIPTION", + } { + t.Run(address, func(t *testing.T) { + tag, err := h.ParseTag(address) + require.NoError(t, err) + assert.Equal(t, address, tag.GetAddressString()) + + reparsed, err := h.ParseTag(tag.GetAddressString()) + require.NoError(t, err, "the rendered address must parse") + assert.Equal(t, tag, reparsed) + }) + } +} + +// The bracket a BACnet address carries is an index into a property's array - one element, which +// is a scalar. It already means what the unified notation says an index means, so nothing about +// this driver's addresses changed. +func TestTagHandler_APropertyArrayIndexIsAScalar(t *testing.T) { + tag, err := NewTagHandler().ParseTag("ANALOG_VALUE,2/PRESENT_VALUE[3]") + require.NoError(t, err) + assert.Empty(t, tag.GetArrayInfo()) + assert.Equal(t, uint(3), *tag.(BacNetPlcTag).GetProperties()[0].ArrayIndex, "the element at index 3") +} diff --git a/plc4go/internal/cbus/Query.go b/plc4go/internal/cbus/Query.go index 31539e3a10c..c069daed481 100644 --- a/plc4go/internal/cbus/Query.go +++ b/plc4go/internal/cbus/Query.go @@ -78,7 +78,7 @@ func (u unitInfoQuery) GetArrayInfo() []apiModel.ArrayInfo { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(u.numElements), + UpperBound: uint32(u.numElements) - 1, }, } } diff --git a/plc4go/internal/cbus/Query_test.go b/plc4go/internal/cbus/Query_test.go index 601fbc42858..2409199d5bb 100644 --- a/plc4go/internal/cbus/Query_test.go +++ b/plc4go/internal/cbus/Query_test.go @@ -88,7 +88,7 @@ func Test_unitInfoQuery_GetArrayInfo(t *testing.T) { want: []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: 2, + UpperBound: 1, }, }, }, diff --git a/plc4go/internal/cbus/Tag.go b/plc4go/internal/cbus/Tag.go index e429ba2f570..a1a90e548a4 100644 --- a/plc4go/internal/cbus/Tag.go +++ b/plc4go/internal/cbus/Tag.go @@ -298,11 +298,12 @@ func (s statusTag) GetValueType() apiValues.PlcValueType { } func (s statusTag) GetArrayInfo() []apiModel.ArrayInfo { - if s.numElements != 1 { + if s.numElements > 1 { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(s.numElements), + UpperBound: uint32(s.numElements) - 1, + Range: true, }, } } @@ -439,11 +440,12 @@ func (c calRecallTag) GetValueType() apiValues.PlcValueType { } func (c calRecallTag) GetArrayInfo() []apiModel.ArrayInfo { - if c.count != 1 { + if c.count > 1 { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(c.count), + UpperBound: uint32(c.count) - 1, + Range: true, }, } } @@ -506,11 +508,12 @@ func (c calIdentifyTag) GetValueType() apiValues.PlcValueType { } func (c calIdentifyTag) GetArrayInfo() []apiModel.ArrayInfo { - if c.numElements != 1 { + if c.numElements > 1 { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(c.numElements), + UpperBound: uint32(c.numElements) - 1, + Range: true, }, } } @@ -573,11 +576,12 @@ func (c calGetStatusTag) GetValueType() apiValues.PlcValueType { } func (c calGetStatusTag) GetArrayInfo() []apiModel.ArrayInfo { - if c.count != 1 { + if c.count > 1 { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(c.count), + UpperBound: uint32(c.count) - 1, + Range: true, }, } } @@ -648,11 +652,12 @@ func (s salTag) GetValueType() apiValues.PlcValueType { } func (s salTag) GetArrayInfo() []apiModel.ArrayInfo { - if s.numElements != 1 { + if s.numElements > 1 { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(s.numElements), + UpperBound: uint32(s.numElements) - 1, + Range: true, }, } } @@ -729,11 +734,12 @@ func (s salMonitorTag) GetValueType() apiValues.PlcValueType { } func (s salMonitorTag) GetArrayInfo() []apiModel.ArrayInfo { - if s.numElements != 1 { + if s.numElements > 1 { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(s.numElements), + UpperBound: uint32(s.numElements) - 1, + Range: true, }, } } @@ -807,11 +813,12 @@ func (m mmiMonitorTag) GetValueType() apiValues.PlcValueType { } func (m mmiMonitorTag) GetArrayInfo() []apiModel.ArrayInfo { - if m.numElements != 1 { + if m.numElements > 1 { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(m.numElements), + UpperBound: uint32(m.numElements) - 1, + Range: true, }, } } diff --git a/plc4go/internal/cbus/TagHandler_test.go b/plc4go/internal/cbus/TagHandler_test.go index 03b52d8bc3a..886ab98b22a 100644 --- a/plc4go/internal/cbus/TagHandler_test.go +++ b/plc4go/internal/cbus/TagHandler_test.go @@ -24,6 +24,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" readWriteModel "github.com/apache/plc4x/plc4go/protocols/cbus/readwrite/model" @@ -1551,3 +1552,26 @@ func TestTagHandler_unitAddressFromArgument(t *testing.T) { }) } } + +// C-Bus addresses are commands, not memory locations, and their brackets hold the arguments of +// one CAL command - "recall=[param, count]" - rather than a selection appended to an address. +// There is nothing here for the array notation to replace, so these addresses are unchanged; +// what a command reads several of is still reported through GetArrayInfo like any other list. +func TestTagHandler_CommandArgumentsAreNotAnArraySelection(t *testing.T) { + handler := NewTagHandler() + + several, err := handler.ParseTag("cal/0/recall=[0x20, 4]") + require.NoError(t, err) + require.Len(t, several.GetArrayInfo(), 1) + assert.Equal(t, uint32(4), several.GetArrayInfo()[0].GetSize()) + assert.True(t, several.GetArrayInfo()[0].IsRange()) + + one, err := handler.ParseTag("cal/0/recall=[0x20, 1]") + require.NoError(t, err) + assert.Empty(t, one.GetArrayInfo(), "one value is a scalar") + + // The notation's own spelling is not accepted here, because there is no address to append a + // selection to. + _, err = handler.ParseTag("cal/0/recall=[0x20][0..3]") + assert.Error(t, err) +} diff --git a/plc4go/internal/cbus/Tag_test.go b/plc4go/internal/cbus/Tag_test.go index d54244269b5..80f48ae9ed7 100644 --- a/plc4go/internal/cbus/Tag_test.go +++ b/plc4go/internal/cbus/Tag_test.go @@ -29,7 +29,6 @@ import ( apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" apiValues "github.com/apache/plc4x/plc4go/pkg/api/values" readWriteModel "github.com/apache/plc4x/plc4go/protocols/cbus/readwrite/model" - spiModel "github.com/apache/plc4x/plc4go/spi/model" "github.com/apache/plc4x/plc4go/spi/utils" ) @@ -291,9 +290,8 @@ func Test_calGetStatusTag_GetArrayInfo(t *testing.T) { }{ { name: "get array info", - want: []apiModel.ArrayInfo{ - &spiModel.DefaultArrayInfo{}, - }, + // A count below two is not an array: the tags report no dimensions. + want: []apiModel.ArrayInfo{}, }, { name: "one element", @@ -570,9 +568,8 @@ func Test_calIdentifyTag_GetArrayInfo(t *testing.T) { }{ { name: "get empty array info", - want: []apiModel.ArrayInfo{ - &spiModel.DefaultArrayInfo{}, - }, + // A count below two is not an array: the tags report no dimensions. + want: []apiModel.ArrayInfo{}, }, { name: "one element", @@ -810,9 +807,8 @@ func Test_calRecallTag_GetArrayInfo(t *testing.T) { }{ { name: "empty array info", - want: []apiModel.ArrayInfo{ - &spiModel.DefaultArrayInfo{}, - }, + // A count below two is not an array: the tags report no dimensions. + want: []apiModel.ArrayInfo{}, }, { name: "one element", @@ -1281,9 +1277,8 @@ func Test_mmiMonitorTag_GetArrayInfo(t *testing.T) { }{ { name: "mmi monitor tag", - want: []apiModel.ArrayInfo{ - &spiModel.DefaultArrayInfo{}, - }, + // A count below two is not an array: the tags report no dimensions. + want: []apiModel.ArrayInfo{}, }, { name: "one element", @@ -1597,9 +1592,8 @@ func Test_salMonitorTag_GetArrayInfo(t *testing.T) { }{ { name: "get empty array info", - want: []apiModel.ArrayInfo{ - &spiModel.DefaultArrayInfo{}, - }, + // A count below two is not an array: the tags report no dimensions. + want: []apiModel.ArrayInfo{}, }, { name: "one element", @@ -1907,9 +1901,8 @@ func Test_salTag_GetArrayInfo(t *testing.T) { }{ { name: "get empty array info", - want: []apiModel.ArrayInfo{ - &spiModel.DefaultArrayInfo{}, - }, + // A count below two is not an array: the tags report no dimensions. + want: []apiModel.ArrayInfo{}, }, { name: "one element", @@ -2273,9 +2266,8 @@ func Test_statusTag_GetArrayInfo(t *testing.T) { }{ { name: "get empty array info", - want: []apiModel.ArrayInfo{ - &spiModel.DefaultArrayInfo{}, - }, + // A count below two is not an array: the tags report no dimensions. + want: []apiModel.ArrayInfo{}, }, { name: "one element", diff --git a/plc4go/internal/eip/BitStringValues_test.go b/plc4go/internal/eip/BitStringValues_test.go index b08614719f6..bc4baadf5a6 100644 --- a/plc4go/internal/eip/BitStringValues_test.go +++ b/plc4go/internal/eip/BitStringValues_test.go @@ -76,7 +76,7 @@ func TestParsePlcValueArrayOfDWORDs(t *testing.T) { raw = binary.LittleEndian.AppendUint32(raw, 0xFFFFFFFF) raw = binary.LittleEndian.AppendUint32(raw, 0x80000000) - value, err := parsePlcValue(mustTag(t, "%d[0]:DWORD:3"), raw, readWriteModel.CIPDataTypeCode_DWORD) + value, err := parsePlcValue(mustTag(t, "%d[0..2]:DWORD"), raw, readWriteModel.CIPDataTypeCode_DWORD) require.NoError(t, err) require.True(t, value.IsList()) list := value.GetList() @@ -90,7 +90,7 @@ func TestParsePlcValueArrayOfLWORDs(t *testing.T) { raw := binary.LittleEndian.AppendUint64(nil, 1) raw = binary.LittleEndian.AppendUint64(raw, 0xFFFFFFFFFFFFFFFF) - value, err := parsePlcValue(mustTag(t, "%l[0]:LWORD:2"), raw, readWriteModel.CIPDataTypeCode_LWORD) + value, err := parsePlcValue(mustTag(t, "%l[0..1]:LWORD"), raw, readWriteModel.CIPDataTypeCode_LWORD) require.NoError(t, err) list := value.GetList() require.Len(t, list, 2) @@ -99,13 +99,13 @@ func TestParsePlcValueArrayOfLWORDs(t *testing.T) { } func TestParsePlcValueArrayOfBYTEsAndWORDs(t *testing.T) { - bytesValue, err := parsePlcValue(mustTag(t, "%b[0]:BYTE:3"), []byte{0x01, 0x80, 0xFF}, readWriteModel.CIPDataTypeCode_BYTE) + bytesValue, err := parsePlcValue(mustTag(t, "%b[0..2]:BYTE"), []byte{0x01, 0x80, 0xFF}, readWriteModel.CIPDataTypeCode_BYTE) require.NoError(t, err) assert.Equal(t, uint8(0xFF), bytesValue.GetList()[2].GetUint8()) raw := binary.LittleEndian.AppendUint16(nil, 0x8000) raw = binary.LittleEndian.AppendUint16(raw, 0xFFFF) - wordsValue, err := parsePlcValue(mustTag(t, "%w[0]:WORD:2"), raw, readWriteModel.CIPDataTypeCode_WORD) + wordsValue, err := parsePlcValue(mustTag(t, "%w[0..1]:WORD"), raw, readWriteModel.CIPDataTypeCode_WORD) require.NoError(t, err) assert.Equal(t, uint16(0x8000), wordsValue.GetList()[0].GetUint16()) assert.Equal(t, uint16(0xFFFF), wordsValue.GetList()[1].GetUint16()) @@ -114,10 +114,10 @@ func TestParsePlcValueArrayOfBYTEsAndWORDs(t *testing.T) { // The bit-string types are fixed size, so a reply shorter than the declared element count is // reported as an error rather than read past the end of the buffer. func TestParsePlcValueBitStringShortReply(t *testing.T) { - _, err := parsePlcValue(mustTag(t, "%d[0]:DWORD:8"), binary.LittleEndian.AppendUint32(nil, 1), readWriteModel.CIPDataTypeCode_DWORD) + _, err := parsePlcValue(mustTag(t, "%d[0..7]:DWORD"), binary.LittleEndian.AppendUint32(nil, 1), readWriteModel.CIPDataTypeCode_DWORD) assert.Error(t, err) - _, err = parsePlcValue(mustTag(t, "%l[0]:LWORD:4"), binary.LittleEndian.AppendUint64(nil, 1), readWriteModel.CIPDataTypeCode_LWORD) + _, err = parsePlcValue(mustTag(t, "%l[0..3]:LWORD"), binary.LittleEndian.AppendUint64(nil, 1), readWriteModel.CIPDataTypeCode_LWORD) assert.Error(t, err) } diff --git a/plc4go/internal/eip/Reader.go b/plc4go/internal/eip/Reader.go index 977e37d70e8..0aa984b4c8b 100644 --- a/plc4go/internal/eip/Reader.go +++ b/plc4go/internal/eip/Reader.go @@ -100,7 +100,7 @@ func (m *Reader) readWithoutMessageRouter(ctx context.Context, readRequest apiMo plcValues := map[string]values.PlcValue{} for _, tagName := range readRequest.GetTagNames() { tag := readRequest.GetTag(tagName).(PlcTag) - ansi, err := toAnsi(tag.GetTag()) + ansi, err := cipPathOf(tag) if err != nil { responseCodes[tagName] = apiModel.PlcResponseCode_INVALID_ADDRESS continue @@ -212,7 +212,7 @@ func (m *Reader) buildReadService(readRequest apiModel.PlcReadRequest) (readWrit requests := make([]readWriteModel.CipService, 0, len(tagNames)) for _, tagName := range tagNames { tag := readRequest.GetTag(tagName).(PlcTag) - ansi, err := toAnsi(tag.GetTag()) + ansi, err := cipPathOf(tag) if err != nil { return nil, errors.Wrapf(err, "error encoding eip ansi for tag %s", tagName) } @@ -335,6 +335,23 @@ func ansiPad(identifier string) *uint8 { // runs for every tag of every request, and compiling a pattern per call is not free. var resourceAddressPattern = regexp.MustCompile("([.\\[\\]])*([A-Za-z_0-9]+){1}") +// cipPathOf is the CIP path a tag's address describes: the members named in the address, +// followed by a member segment for the element the selection starts at. +// +// A selection starting at the first element carries no member segment: that is what a request +// without one already means, so emitting MemberID(0) would add two bytes that say nothing - and +// every address that used to be written "tag:TYPE:n" was sent without one. Feature 002 found +// this the hard way, when the emitted MemberID(0) broke a recorded exchange no unit test covered. +func cipPathOf(tag PlcTag) ([]byte, error) { + address := tag.GetTag() + if selection := tag.GetSelection(); len(selection) > 0 { + if offset := selection[0].GetLowerBound() - selection[0].GetBase(); offset > 0 { + address = fmt.Sprintf("%s[%d]", address, offset) + } + } + return toAnsi(address) +} + func toAnsi(tag string) ([]byte, error) { ctx := context.TODO() diff --git a/plc4go/internal/eip/Reader_test.go b/plc4go/internal/eip/Reader_test.go index 0ee2ca17fe0..b3d62da2b2f 100644 --- a/plc4go/internal/eip/Reader_test.go +++ b/plc4go/internal/eip/Reader_test.go @@ -172,3 +172,31 @@ func TestToAnsiAcceptsTheLargestIndex(t *testing.T) { require.NoError(t, err) assert.Equal(t, []byte{0x91, 0x01, 0x61, 0x00, 0x28, 0xFF}, actual) } + +// The CIP path carries a member segment only when the selection starts past the first element. +// A selection starting at 0 is what a request without a member segment already means, so +// emitting MemberID(0) would add two bytes that say nothing - and every address that used to be +// written "%rate:DINT:4" was sent without one. Feature 002 found this the hard way: the extra +// segment broke a recorded exchange that no unit test covered. +func TestCipPathOmitsAZeroMemberSegment(t *testing.T) { + handler := NewTagHandler() + + countFromTheStart, err := handler.ParseTag("%rate[0..3]:DINT") + require.NoError(t, err) + fromStart, err := cipPathOf(countFromTheStart.(PlcTag)) + require.NoError(t, err) + + scalar, err := handler.ParseTag("%rate:DINT") + require.NoError(t, err) + bare, err := cipPathOf(scalar.(PlcTag)) + require.NoError(t, err) + + assert.Equal(t, bare, fromStart, "a selection starting at 0 encodes the same path as none") + + offset, err := handler.ParseTag("%rate[2..3]:DINT") + require.NoError(t, err) + withOffset, err := cipPathOf(offset.(PlcTag)) + require.NoError(t, err) + assert.Equal(t, append(append([]byte{}, bare...), 0x28, 0x02), withOffset, + "a selection starting at 2 adds MemberID(2)") +} diff --git a/plc4go/internal/eip/Tag.go b/plc4go/internal/eip/Tag.go index 0289acad4ce..64c273ef6c4 100644 --- a/plc4go/internal/eip/Tag.go +++ b/plc4go/internal/eip/Tag.go @@ -26,6 +26,7 @@ import ( apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" apiValues "github.com/apache/plc4x/plc4go/pkg/api/values" readWriteModel "github.com/apache/plc4x/plc4go/protocols/eip/readwrite/model" + spiModel "github.com/apache/plc4x/plc4go/spi/model" "github.com/apache/plc4x/plc4go/spi/utils" ) @@ -36,24 +37,49 @@ type PlcTag interface { GetTag() string GetType() readWriteModel.CIPDataTypeCode GetElementNb() uint16 + // GetSelection reports what the address selects, which drives the CIP path and the element + // count. It is not what GetArrayInfo reports - see there. + GetSelection() []apiModel.ArrayInfo } +// maxElements is what a CIP request can ask for: the element count is carried in 16 bits. +const maxElements = 65535 + type plcTag struct { Tag string Type readWriteModel.CIPDataTypeCode - ElementNb uint16 + Selection []apiModel.ArrayInfo } +// NewTag builds a tag selecting elementNb elements from the start of tag, which is the shape the +// element-count form described. An address selecting a range is built with NewTagWithSelection. func NewTag(tag string, _type readWriteModel.CIPDataTypeCode, elementNb uint16) PlcTag { + if elementNb < 1 { + elementNb = 1 + } + var selection []apiModel.ArrayInfo + if elementNb > 1 { + selection = []apiModel.ArrayInfo{&spiModel.DefaultArrayInfo{UpperBound: uint32(elementNb) - 1, Range: true}} + } + return NewTagWithSelection(tag, _type, selection) +} + +func NewTagWithSelection(tag string, _type readWriteModel.CIPDataTypeCode, selection []apiModel.ArrayInfo) PlcTag { return plcTag{ Tag: tag, Type: _type, - ElementNb: elementNb, + Selection: selection, } } +// GetAddressString mirrors what the address pattern accepts, so re-parsing it yields an equal +// tag: tag[selection][:dataType], with the selection before the type. func (m plcTag) GetAddressString() string { - return m.GetTag() + address := "%" + m.Tag + spiModel.RenderArrayExpression(m.Selection) + if m.Type != 0 { + address = address + ":" + m.Type.String() + } + return address } func (m plcTag) GetValueType() apiValues.PlcValueType { @@ -64,10 +90,26 @@ func (m plcTag) GetValueType() apiValues.PlcValueType { } } +// GetArrayInfo reports the shape of the value the caller receives, so a consumer can tell a +// scalar from a list without knowing the protocol: empty for a scalar, one entry per dimension +// for an array. A bare index selects one element and so reports empty; a range reports its +// dimensions even when it spans a single element. +// +// This is not what the driver fetches - reading one element of an array still walks a member +// path, which the selection describes. func (m plcTag) GetArrayInfo() []apiModel.ArrayInfo { + for _, dimension := range m.Selection { + if dimension.IsRange() { + return m.Selection + } + } return []apiModel.ArrayInfo{} } +func (m plcTag) GetSelection() []apiModel.ArrayInfo { + return m.Selection +} + func (m plcTag) GetTag() string { return m.Tag } @@ -76,8 +118,23 @@ func (m plcTag) GetType() readWriteModel.CIPDataTypeCode { return m.Type } +// GetElementNb is how many elements the request asks the device for, derived from the selection; +// a tag that selects nothing explicitly reads a single element. func (m plcTag) GetElementNb() uint16 { - return m.ElementNb + // Computed as a uint64: the product of several dimensions wraps a uint32 long before it + // reaches the wire, and the count is carried in 16 bits there. The handler refuses a selection + // larger than that, so the conversion below cannot narrow a value anyone asked for. + elements := uint64(1) + for _, dimension := range m.Selection { + elements *= uint64(dimension.GetSize()) + } + if elements < 1 { + return 1 + } + if elements > maxElements { + return maxElements + } + return uint16(elements) } func (m plcTag) Serialize() ([]byte, error) { @@ -113,7 +170,7 @@ func (m plcTag) SerializeWithWriteBuffer(ctx context.Context, wb utils.WriteBuff } } - if err := wb.WriteUint16("elementNb", 16, m.ElementNb); err != nil { + if err := wb.WriteUint16("elementNb", 16, m.GetElementNb()); err != nil { return err } diff --git a/plc4go/internal/eip/TagHandler.go b/plc4go/internal/eip/TagHandler.go index 9b5bb999d9c..5358a6be5e5 100644 --- a/plc4go/internal/eip/TagHandler.go +++ b/plc4go/internal/eip/TagHandler.go @@ -22,10 +22,11 @@ package eip import ( "fmt" "regexp" - "strconv" apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" "github.com/apache/plc4x/plc4go/protocols/eip/readwrite/model" + "github.com/apache/plc4x/plc4go/spi/errors" + spiModel "github.com/apache/plc4x/plc4go/spi/model" ) type TagHandler struct { @@ -34,25 +35,35 @@ type TagHandler struct { func NewTagHandler() TagHandler { return TagHandler{ - addressPattern: regexp.MustCompile(`^%(?P[%a-zA-Z_.0-9]+\[?[0-9]*]?):?(?P[A-Z]*):?(?P[0-9]*)`), + // The selection sits before the type, as it does everywhere else. The trailing + // ":elementNb" count is gone: "%rate:DINT:4" is now written "%rate[0..3]:DINT". + addressPattern: regexp.MustCompile(`^%(?P[%a-zA-Z_.0-9]+)` + spiModel.ArrayGroupPattern + `(?::(?P[A-Z]+))?$`), } } const ( - TAG = "tag" - DATA_TYPE = "dataType" - ELEMENT_NB = "elementNb" + TAG = "tag" + DATA_TYPE = "dataType" + ARRAY = "array" ) +// eipConstraints is what a CIP request can encode of a selection: one dimension, starting no +// later than 255 - the member segment that carries the offset is a uint 8. +var eipConstraints = spiModel.SingleDimension. + WithMaxIndex(255). + WithOnlyTrailingDimensionMayBeRange(true) + func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { matches := m.addressPattern.FindStringSubmatch(tagAddress) if matches == nil { - return nil, fmt.Errorf("invalid tag address: %s", tagAddress) + // "%rate:DINT:4" and "%rate:DINT[4]" both used to parse. Neither does now, so say what + // to write instead rather than reporting only that the address did not match. + return nil, spiModel.InvalidAddressError(tagAddress, "%tag[selection]:TYPE - for example %rate[0..3]:DINT") } tagName := matches[m.addressPattern.SubexpIndex(TAG)] dataTypeStr := matches[m.addressPattern.SubexpIndex(DATA_TYPE)] - elementNbStr := matches[m.addressPattern.SubexpIndex(ELEMENT_NB)] + arrayExpression := matches[m.addressPattern.SubexpIndex(ARRAY)] var dataType model.CIPDataTypeCode if dataTypeStr == "" { @@ -65,16 +76,24 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { } } - elementNb := uint16(1) - if elementNbStr != "" { - nb, err := strconv.ParseUint(elementNbStr, 10, 16) - if err != nil { - return nil, fmt.Errorf("invalid element count: %s", elementNbStr) - } - elementNb = uint16(nb) + selection, err := spiModel.ParseArrayExpression(arrayExpression, tagAddress, eipConstraints) + if err != nil { + return nil, err + } + + // A CIP request carries its element count in 16 bits, so a larger selection would be narrowed + // on the way out - "%arr[0..65535]" would ask the device for zero elements. Refuse it rather + // than send a request that means something else. + elements := uint64(1) + for _, dimension := range selection { + elements *= uint64(dimension.GetSize()) + } + if elements > maxElements { + return nil, errors.Errorf("tag '%s' selects %d elements, more than the %d a CIP request can ask for", + tagAddress, elements, maxElements) } - return NewTag(tagName, dataType, elementNb), nil + return NewTagWithSelection(tagName, dataType, selection), nil } func (m TagHandler) ParseQuery(query string) (apiModel.PlcQuery, error) { diff --git a/plc4go/internal/eip/TagHandler_test.go b/plc4go/internal/eip/TagHandler_test.go index 8b711491e7a..b64c685f4b6 100644 --- a/plc4go/internal/eip/TagHandler_test.go +++ b/plc4go/internal/eip/TagHandler_test.go @@ -49,11 +49,11 @@ func TestParseTagWithDINT(t *testing.T) { } func TestParseTagArray(t *testing.T) { - // %arr[0]:DINT:4 → DINT, elementNb 4, tag preserved as arr[0] - tag, err := NewTagHandler().ParseTag("%arr[0]:DINT:4") + // %arr[0..3]:DINT → DINT, four elements; the selection is held apart from the tag name + tag, err := NewTagHandler().ParseTag("%arr[0..3]:DINT") require.NoError(t, err) plcTag := tag.(PlcTag) - assert.Equal(t, "arr[0]", plcTag.GetTag()) + assert.Equal(t, "arr", plcTag.GetTag()) assert.Equal(t, readWriteModel.CIPDataTypeCode_DINT, plcTag.GetType()) assert.Equal(t, uint16(4), plcTag.GetElementNb()) } @@ -109,14 +109,14 @@ func TestParseTagGarbageInput(t *testing.T) { // Invalid prefix (not %): error _, err := NewTagHandler().ParseTag("rate:DINT") require.Error(t, err) - assert.Contains(t, err.Error(), "invalid tag address") + assert.Contains(t, err.Error(), "invalid address") } func TestParseTagEmptyString(t *testing.T) { // Empty string: error _, err := NewTagHandler().ParseTag("") require.Error(t, err) - assert.Contains(t, err.Error(), "invalid tag address") + assert.Contains(t, err.Error(), "invalid address") } func TestParseTagWithTypeButNoElements(t *testing.T) { @@ -130,10 +130,82 @@ func TestParseTagWithTypeButNoElements(t *testing.T) { } func TestParseTagLargeElementCount(t *testing.T) { - // %arr[0]:DINT:1000 → elementNb 1000 - tag, err := NewTagHandler().ParseTag("%arr[0]:DINT:1000") + // %arr[0..999]:DINT → 1000 elements + tag, err := NewTagHandler().ParseTag("%arr[0..999]:DINT") require.NoError(t, err) plcTag := tag.(PlcTag) assert.Equal(t, readWriteModel.CIPDataTypeCode_DINT, plcTag.GetType()) assert.Equal(t, uint16(1000), plcTag.GetElementNb()) } + +// The element count used to be a third colon-separated field. It is a range now, and the old +// spelling must not parse - an address whose meaning changed has to fail rather than quietly +// read something else. +func TestParseTagRejectsTheOldElementCount(t *testing.T) { + for _, address := range []string{"%rate:DINT:4", "%arr[0]:DINT:2", "%rate:DINT[4]"} { + t.Run(address, func(t *testing.T) { + _, err := NewTagHandler().ParseTag(address) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid address", address) + }) + } +} + +// What a CIP request can encode: one dimension, starting no later than 255, since the member +// segment that carries the offset is a uint 8. +func TestParseTagRejectsWhatCipCannotEncode(t *testing.T) { + for _, address := range []string{"%arr[0..1][2..3]:DINT", "%arr[256]:DINT", "%arr[300..302]:DINT"} { + t.Run(address, func(t *testing.T) { + _, err := NewTagHandler().ParseTag(address) + require.Error(t, err, address) + }) + } +} + +// A rendered address must re-parse to the same tag, so a tag can be carried as a string. +func TestParseTagRoundTrips(t *testing.T) { + for _, address := range []string{"%rate:DINT", "%arr[4]:DINT", "%arr[0..3]:DINT", "%arr[4..7;1]:INT", "%struct.member:INT"} { + t.Run(address, func(t *testing.T) { + tag, err := NewTagHandler().ParseTag(address) + require.NoError(t, err) + assert.Equal(t, address, tag.GetAddressString()) + + reparsed, err := NewTagHandler().ParseTag(tag.GetAddressString()) + require.NoError(t, err) + assert.Equal(t, tag, reparsed) + }) + } +} + +// A bare index selects one element and so reports no dimensions; a range reports one even when +// it spans a single element. This is what a consumer reads to tell a value from a list. +func TestGetArrayInfoDistinguishesAScalarFromAList(t *testing.T) { + scalar, err := NewTagHandler().ParseTag("%arr[4]:DINT") + require.NoError(t, err) + assert.Empty(t, scalar.GetArrayInfo()) + assert.Equal(t, uint16(1), scalar.(PlcTag).GetElementNb()) + + oneElementRange, err := NewTagHandler().ParseTag("%arr[4..4]:DINT") + require.NoError(t, err) + assert.Len(t, oneElementRange.GetArrayInfo(), 1) + assert.Equal(t, uint16(1), oneElementRange.(PlcTag).GetElementNb()) + + list, err := NewTagHandler().ParseTag("%arr[0..3]:DINT") + require.NoError(t, err) + assert.Len(t, list.GetArrayInfo(), 1) + assert.Equal(t, uint16(4), list.(PlcTag).GetElementNb()) +} + +// A CIP request carries its element count in 16 bits, so a selection larger than that cannot be +// asked for. It used to be narrowed silently: "%arr[0..65535]" is 65536 elements, which is zero +// in a uint16 - the device would have been asked for nothing at all. +func TestParseTag_refusesACountTheRequestCannotCarry(t *testing.T) { + _, err := NewTagHandler().ParseTag("%arr[0..65535]:DINT") + require.Error(t, err) + assert.Contains(t, err.Error(), "65535") + + // One below the limit is still asked for in full. + tag, err := NewTagHandler().ParseTag("%arr[0..65534]:DINT") + require.NoError(t, err) + assert.Equal(t, uint16(65535), tag.(PlcTag).GetElementNb()) +} diff --git a/plc4go/internal/eip/UnsignedIntegerValues_test.go b/plc4go/internal/eip/UnsignedIntegerValues_test.go index db85887ee29..191d20e6bdd 100644 --- a/plc4go/internal/eip/UnsignedIntegerValues_test.go +++ b/plc4go/internal/eip/UnsignedIntegerValues_test.go @@ -75,7 +75,7 @@ func TestParsePlcValueArrayOfUDINTs(t *testing.T) { raw = binary.LittleEndian.AppendUint32(raw, 0xFFFFFFFF) raw = binary.LittleEndian.AppendUint32(raw, 0x80000000) - value, err := parsePlcValue(mustTag(t, "%d[0]:UDINT:3"), raw, readWriteModel.CIPDataTypeCode_UDINT) + value, err := parsePlcValue(mustTag(t, "%d[0..2]:UDINT"), raw, readWriteModel.CIPDataTypeCode_UDINT) require.NoError(t, err) require.True(t, value.IsList()) list := value.GetList() @@ -89,7 +89,7 @@ func TestParsePlcValueArrayOfULINTs(t *testing.T) { raw := binary.LittleEndian.AppendUint64(nil, 1) raw = binary.LittleEndian.AppendUint64(raw, 0xFFFFFFFFFFFFFFFF) - value, err := parsePlcValue(mustTag(t, "%l[0]:ULINT:2"), raw, readWriteModel.CIPDataTypeCode_ULINT) + value, err := parsePlcValue(mustTag(t, "%l[0..1]:ULINT"), raw, readWriteModel.CIPDataTypeCode_ULINT) require.NoError(t, err) list := value.GetList() require.Len(t, list, 2) @@ -98,23 +98,23 @@ func TestParsePlcValueArrayOfULINTs(t *testing.T) { } func TestParsePlcValueArrayOfUSINTsAndUINTs(t *testing.T) { - usints, err := parsePlcValue(mustTag(t, "%b[0]:USINT:3"), []byte{0x01, 0x80, 0xFF}, readWriteModel.CIPDataTypeCode_USINT) + usints, err := parsePlcValue(mustTag(t, "%b[0..2]:USINT"), []byte{0x01, 0x80, 0xFF}, readWriteModel.CIPDataTypeCode_USINT) require.NoError(t, err) assert.Equal(t, uint8(0xFF), usints.GetList()[2].GetUint8()) raw := binary.LittleEndian.AppendUint16(nil, 0x8000) raw = binary.LittleEndian.AppendUint16(raw, 0xFFFF) - uints, err := parsePlcValue(mustTag(t, "%w[0]:UINT:2"), raw, readWriteModel.CIPDataTypeCode_UINT) + uints, err := parsePlcValue(mustTag(t, "%w[0..1]:UINT"), raw, readWriteModel.CIPDataTypeCode_UINT) require.NoError(t, err) assert.Equal(t, uint16(0x8000), uints.GetList()[0].GetUint16()) assert.Equal(t, uint16(0xFFFF), uints.GetList()[1].GetUint16()) } func TestParsePlcValueUnsignedIntegerShortReply(t *testing.T) { - _, err := parsePlcValue(mustTag(t, "%d[0]:UDINT:8"), binary.LittleEndian.AppendUint32(nil, 1), readWriteModel.CIPDataTypeCode_UDINT) + _, err := parsePlcValue(mustTag(t, "%d[0..7]:UDINT"), binary.LittleEndian.AppendUint32(nil, 1), readWriteModel.CIPDataTypeCode_UDINT) assert.Error(t, err) - _, err = parsePlcValue(mustTag(t, "%l[0]:ULINT:4"), binary.LittleEndian.AppendUint64(nil, 1), readWriteModel.CIPDataTypeCode_ULINT) + _, err = parsePlcValue(mustTag(t, "%l[0..3]:ULINT"), binary.LittleEndian.AppendUint64(nil, 1), readWriteModel.CIPDataTypeCode_ULINT) assert.Error(t, err) } diff --git a/plc4go/internal/eip/Values.go b/plc4go/internal/eip/Values.go index 29f63c44a22..794c6b13fd0 100644 --- a/plc4go/internal/eip/Values.go +++ b/plc4go/internal/eip/Values.go @@ -197,12 +197,17 @@ func isFixedSize(dataType readWriteModel.CIPDataTypeCode) bool { // wire regardless of the encapsulation byte order. func parsePlcValue(tag PlcTag, rawData []byte, dataType readWriteModel.CIPDataTypeCode) (values.PlcValue, error) { nb := int(elementCount(tag)) + // Whether the caller gets a list is decided by the shape the tag reports, not by how many + // elements it happens to hold: "%arr[4..4]" selects one element and is still a list of one, + // while "%arr[4]" is a scalar. Deciding from the count made the response contradict + // GetArrayInfo, which is what a consumer reads to know which it is. + asList := len(tag.GetArrayInfo()) > 0 codec, fixedSize := fixedSizeCodecs[dataType] if !fixedSize { // STRING and STRUCTURED carry their own length rather than being laid out element by // element, so only the first one can be decoded from the reply. value, err := parseStructured(tag, rawData, dataType) - if err != nil || nb == 1 { + if err != nil || !asList { return value, err } return spiValues.NewPlcList([]values.PlcValue{value}), nil @@ -215,7 +220,7 @@ func parsePlcValue(tag PlcTag, rawData []byte, dataType readWriteModel.CIPDataTy return nil, errors.Errorf("device returned %d bytes for tag '%s', expected %d for %d element(s) of %s", len(rawData), tag.GetTag(), nb*elementSize, nb, dataType) } - if nb == 1 { + if !asList { return codec.read(rawData, 0), nil } list := make([]values.PlcValue, 0, nb) diff --git a/plc4go/internal/eip/Values_test.go b/plc4go/internal/eip/Values_test.go index ba20e1fa921..879e3aedbb2 100644 --- a/plc4go/internal/eip/Values_test.go +++ b/plc4go/internal/eip/Values_test.go @@ -90,7 +90,7 @@ func TestParsePlcValueSingleBOOL(t *testing.T) { func TestParsePlcValueDINTArray(t *testing.T) { raw := []byte{0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00} - value, err := parsePlcValue(mustTag(t, "%arr[0]:DINT:2"), raw, readWriteModel.CIPDataTypeCode_DINT) + value, err := parsePlcValue(mustTag(t, "%arr[0..1]:DINT"), raw, readWriteModel.CIPDataTypeCode_DINT) require.NoError(t, err) require.True(t, value.IsList()) list := value.GetList() @@ -101,7 +101,7 @@ func TestParsePlcValueDINTArray(t *testing.T) { func TestParsePlcValueShortReplyIsError(t *testing.T) { // 2 DINT elements requested but only 4 bytes returned: must error, not panic (GH-954 thread) - _, err := parsePlcValue(mustTag(t, "%arr[0]:DINT:2"), []byte{0x01, 0x00, 0x00, 0x00}, readWriteModel.CIPDataTypeCode_DINT) + _, err := parsePlcValue(mustTag(t, "%arr[0..1]:DINT"), []byte{0x01, 0x00, 0x00, 0x00}, readWriteModel.CIPDataTypeCode_DINT) require.Error(t, err) } @@ -180,3 +180,29 @@ func TestDecodeResponseCode(t *testing.T) { assert.Equal(t, apiModel.PlcResponseCode_OK, decodeResponseCode(0)) assert.Equal(t, apiModel.PlcResponseCode_INTERNAL_ERROR, decodeResponseCode(5)) } + +// What the caller receives has to match what GetArrayInfo promises: a one-element range is a list +// of one, a bare index is a scalar. The decoder used to decide from the element count, so both +// came back as scalars and the response contradicted the tag. +func TestParsePlcValue_shapeFollowsTheTagNotTheCount(t *testing.T) { + handler := NewTagHandler() + fourBytes := []byte{0x2A, 0x00, 0x00, 0x00} + + scalar, err := handler.ParseTag("%rate[4]:DINT") + require.NoError(t, err) + value, err := parsePlcValue(scalar.(PlcTag), fourBytes, readWriteModel.CIPDataTypeCode_DINT) + require.NoError(t, err) + assert.False(t, value.IsList(), "a bare index selects one element and yields a scalar") + assert.Equal(t, int32(42), value.GetInt32()) + + oneElementRange, err := handler.ParseTag("%rate[4..4]:DINT") + require.NoError(t, err) + value, err = parsePlcValue(oneElementRange.(PlcTag), fourBytes, readWriteModel.CIPDataTypeCode_DINT) + require.NoError(t, err) + assert.True(t, value.IsList(), "a range is a list even when it spans one element") + require.Len(t, value.GetList(), 1) + assert.Equal(t, int32(42), value.GetList()[0].GetInt32()) + + // And the shape the tag reports is the one the value has. + assert.Equal(t, len(oneElementRange.GetArrayInfo()) > 0, value.IsList()) +} diff --git a/plc4go/internal/eip/Writer.go b/plc4go/internal/eip/Writer.go index acbdbe46a73..de6a485641b 100644 --- a/plc4go/internal/eip/Writer.go +++ b/plc4go/internal/eip/Writer.go @@ -94,7 +94,7 @@ func (m *Writer) buildWriteRequest(writeRequest apiModel.PlcWriteRequest, tagNam if err != nil { return nil, errors.Wrapf(err, "error encoding value for tag %s", tagName) } - ansi, err := toAnsi(tag.GetTag()) + ansi, err := cipPathOf(tag) if err != nil { return nil, errors.Wrapf(err, "error encoding eip ansi for tag %s", tagName) } diff --git a/plc4go/internal/eip/mocks_test.go b/plc4go/internal/eip/mocks_test.go index 9e68d70f117..9925eea8d5c 100644 --- a/plc4go/internal/eip/mocks_test.go +++ b/plc4go/internal/eip/mocks_test.go @@ -194,6 +194,52 @@ func (_c *MockPlcTag_GetElementNb_Call) RunAndReturn(run func() uint16) *MockPlc return _c } +// GetSelection provides a mock function for the type MockPlcTag +func (_mock *MockPlcTag) GetSelection() []model.ArrayInfo { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetSelection") + } + + var r0 []model.ArrayInfo + if returnFunc, ok := ret.Get(0).(func() []model.ArrayInfo); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.ArrayInfo) + } + } + return r0 +} + +// MockPlcTag_GetSelection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSelection' +type MockPlcTag_GetSelection_Call struct { + *mock.Call +} + +// GetSelection is a helper method to define mock.On call +func (_e *MockPlcTag_Expecter) GetSelection() *MockPlcTag_GetSelection_Call { + return &MockPlcTag_GetSelection_Call{Call: _e.mock.On("GetSelection")} +} + +func (_c *MockPlcTag_GetSelection_Call) Run(run func()) *MockPlcTag_GetSelection_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockPlcTag_GetSelection_Call) Return(arrayInfos []model.ArrayInfo) *MockPlcTag_GetSelection_Call { + _c.Call.Return(arrayInfos) + return _c +} + +func (_c *MockPlcTag_GetSelection_Call) RunAndReturn(run func() []model.ArrayInfo) *MockPlcTag_GetSelection_Call { + _c.Call.Return(run) + return _c +} + // GetTag provides a mock function for the type MockPlcTag func (_mock *MockPlcTag) GetTag() string { ret := _mock.Called() diff --git a/plc4go/internal/firmata/Driver_test.go b/plc4go/internal/firmata/Driver_test.go index 93dd5a7ca43..967edc42621 100644 --- a/plc4go/internal/firmata/Driver_test.go +++ b/plc4go/internal/firmata/Driver_test.go @@ -49,7 +49,7 @@ func TestDriver_Metadata(t *testing.T) { func TestDriver_CheckTagAddress(t *testing.T) { driver := NewDriver() - assert.NoError(t, driver.CheckTagAddress("digital:4[2]:PULLUP")) + assert.NoError(t, driver.CheckTagAddress("digital:4[0..1]:PULLUP")) assert.NoError(t, driver.CheckTagAddress("analog:4")) assert.Error(t, driver.CheckTagAddress("holding-register:4")) // Browsing isn't supported, so no query is valid. diff --git a/plc4go/internal/firmata/Subscriber_test.go b/plc4go/internal/firmata/Subscriber_test.go index 4396a075a79..63dd67d08cd 100644 --- a/plc4go/internal/firmata/Subscriber_test.go +++ b/plc4go/internal/firmata/Subscriber_test.go @@ -102,7 +102,7 @@ func TestSubscriber_SubscribeDigitalPinWithPullup(t *testing.T) { func TestSubscriber_SubscribeARunOfDigitalPinsAcrossPorts(t *testing.T) { connection, transportInstance := newTestConnection(t) - _, responseCode := subscribe(t, connection, "bar", "digital:6[4]") + _, responseCode := subscribe(t, connection, "bar", "digital:6[0..3]") assert.Equal(t, apiModel.PlcResponseCode_OK, responseCode) assert.Equal(t, []byte{ @@ -117,7 +117,7 @@ func TestSubscriber_SubscribeARunOfDigitalPinsAcrossPorts(t *testing.T) { func TestSubscriber_SubscribeAnalogPin(t *testing.T) { connection, transportInstance := newTestConnection(t) - _, responseCode := subscribe(t, connection, "dial", "analog:2[2]") + _, responseCode := subscribe(t, connection, "dial", "analog:2[0..1]") assert.Equal(t, apiModel.PlcResponseCode_OK, responseCode) assert.Equal(t, []byte{ @@ -213,7 +213,7 @@ func TestSubscriber_IgnoresPinsItDoesNotCover(t *testing.T) { // which changed (plc4j FirmataConnection.publishDigitalEvents). func TestSubscriber_DeliversTheWholeRun(t *testing.T) { connection, _ := newTestConnection(t) - handle, _ := subscribe(t, connection, "bar", "digital:8[3]") + handle, _ := subscribe(t, connection, "bar", "digital:8[0..2]") collect, _ := collectEvents(t, handle) connection.handleIncomingMessage(readWriteModel.NewFirmataMessageDigitalIO(1, []int8{0x05, 0x00})) @@ -247,7 +247,7 @@ func TestSubscriber_DeliversAnalogChanges(t *testing.T) { // A pin of a run the board hasn't sampled yet is reported as -1, the way plc4j fills the gaps. func TestSubscriber_DeliversUnknownAnalogPinsAsMinusOne(t *testing.T) { connection, _ := newTestConnection(t) - handle, _ := subscribe(t, connection, "dials", "analog:3[2]") + handle, _ := subscribe(t, connection, "dials", "analog:3[0..1]") collect, _ := collectEvents(t, handle) connection.handleIncomingMessage(readWriteModel.NewFirmataMessageAnalogIO(3, []int8{0x05, 0x00})) diff --git a/plc4go/internal/firmata/Tag.go b/plc4go/internal/firmata/Tag.go index 0c1ea3090e4..05a86ebd19d 100644 --- a/plc4go/internal/firmata/Tag.go +++ b/plc4go/internal/firmata/Tag.go @@ -65,6 +65,9 @@ type digitalTag struct { // pinMode is the mode a subscription configures the pin as. Nil means the plc4j default of // INPUT; the address syntax lets the user ask for PULLUP instead. pinMode *readWriteModel.PinMode + // explicitRange records whether the address wrote a range; a one-element range is still + // a range, which the quantity cannot say. + explicitRange bool } // analogTag addresses one or more analog pins, which report a 14 bit sample. Ported from plc4j's @@ -72,6 +75,9 @@ type digitalTag struct { type analogTag struct { address uint8 quantity uint8 + // explicitRange records whether the address wrote a range; a one-element range is still + // a range, which the quantity cannot say. + explicitRange bool } var ( @@ -82,12 +88,23 @@ var ( // NewDigitalTag builds a digital tag. A nil pinMode means the pin is configured as a plain INPUT // when it is subscribed to. func NewDigitalTag(address uint8, quantity uint8, pinMode *readWriteModel.PinMode) Tag { - return digitalTag{address: address, quantity: quantity, pinMode: pinMode} + return NewDigitalTagWithShape(address, quantity, pinMode, quantity > 1) +} + +// NewDigitalTagWithShape is NewDigitalTag plus what the address said about its shape: a range is +// an array even when it spans one pin, which the quantity alone cannot carry. +func NewDigitalTagWithShape(address uint8, quantity uint8, pinMode *readWriteModel.PinMode, explicitRange bool) Tag { + return digitalTag{address: address, quantity: quantity, pinMode: pinMode, explicitRange: explicitRange} } // NewAnalogTag builds an analog tag. func NewAnalogTag(address uint8, quantity uint8) Tag { - return analogTag{address: address, quantity: quantity} + return NewAnalogTagWithShape(address, quantity, quantity > 1) +} + +// NewAnalogTagWithShape is NewAnalogTag plus the shape the address wrote. +func NewAnalogTagWithShape(address uint8, quantity uint8, explicitRange bool) Tag { + return analogTag{address: address, quantity: quantity, explicitRange: explicitRange} } func (t digitalTag) GetAddress() uint8 { @@ -108,10 +125,7 @@ func (t digitalTag) GetPinMode() *readWriteModel.PinMode { // unparseable back into the same tag; since plc4go re-parses address strings whenever a tag arrives // wrapped in a DefaultPlcSubscriptionTag, the suffix is kept here. func (t digitalTag) GetAddressString() string { - address := fmt.Sprintf("digital:%d", t.address) - if t.quantity != 1 { - address += fmt.Sprintf("[%d]", t.quantity) - } + address := fmt.Sprintf("digital:%d%s", t.address, spiModel.RenderArrayExpression(t.GetArrayInfo())) if t.pinMode != nil && *t.pinMode == readWriteModel.PinMode_PinModePullup { address += ":PULLUP" } @@ -123,7 +137,7 @@ func (t digitalTag) GetValueType() apiValues.PlcValueType { } func (t digitalTag) GetArrayInfo() []apiModel.ArrayInfo { - return arrayInfoFor(t.quantity) + return arrayInfoFor(t.quantity, t.explicitRange) } // GetPlcSubscriptionType is what a tag which wasn't added through one of the typed builder methods @@ -152,11 +166,7 @@ func (t analogTag) GetNumberOfElements() uint8 { } func (t analogTag) GetAddressString() string { - address := fmt.Sprintf("analog:%d", t.address) - if t.quantity != 1 { - address += fmt.Sprintf("[%d]", t.quantity) - } - return address + return fmt.Sprintf("analog:%d%s", t.address, spiModel.RenderArrayExpression(t.GetArrayInfo())) } // GetValueType mirrors plc4j's FirmataTagAnalog.getPlcValueType. An analog sample is 14 bits wide, @@ -166,7 +176,7 @@ func (t analogTag) GetValueType() apiValues.PlcValueType { } func (t analogTag) GetArrayInfo() []apiModel.ArrayInfo { - return arrayInfoFor(t.quantity) + return arrayInfoFor(t.quantity, t.explicitRange) } // GetPlcSubscriptionType is what a tag which wasn't added through one of the typed builder methods @@ -186,12 +196,17 @@ func (t analogTag) String() string { // arrayInfoFor reports a tag covering a single pin as a scalar and everything else as an array, the // way plc4j's FirmataTag subclasses do. -func arrayInfoFor(quantity uint8) []apiModel.ArrayInfo { - if quantity != 1 { +// arrayInfoFor reports the shape of the value the caller receives: a run of pins is a list, a +// single pin is a scalar. The indices are relative to the value, not to the address - a firmata +// address is a pin number, so the driver folds the start of the selection into it. +func arrayInfoFor(quantity uint8, explicitRange bool) []apiModel.ArrayInfo { + // The flag decides the shape; the count only sizes it. + if explicitRange { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(quantity), + UpperBound: uint32(quantity) - 1, + Range: true, }, } } diff --git a/plc4go/internal/firmata/TagHandler.go b/plc4go/internal/firmata/TagHandler.go index 7648667dc9a..1afa61eaf90 100644 --- a/plc4go/internal/firmata/TagHandler.go +++ b/plc4go/internal/firmata/TagHandler.go @@ -28,13 +28,15 @@ import ( apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" readWriteModel "github.com/apache/plc4x/plc4go/protocols/firmata/readwrite/model" "github.com/apache/plc4x/plc4go/spi/errors" + spiModel "github.com/apache/plc4x/plc4go/spi/model" "github.com/apache/plc4x/plc4go/spi/options" "github.com/apache/plc4x/plc4go/spi/utils" ) -// addressPattern is the pin number and the optional run length every firmata address starts with, -// ported verbatim from plc4j's FirmataTag.ADDRESS_PATTERN. -const addressPattern = `(?P
\d+)(\[(?P\d+)])?` +// addressPattern is the pin number and the optional selection every firmata address starts with, +// ported from plc4j's FirmataTag.ADDRESS_PATTERN. Firmata has no type suffix, so the selection +// ends the address - apart from the digital form's PULLUP mode. +var addressPattern = `(?P
\d+)` + spiModel.ArrayGroupPattern // TagHandler parses firmata tag addresses. There are exactly two forms, ported from plc4j's // FirmataTagDigital.ADDRESS_PATTERN and FirmataTagAnalog.ADDRESS_PATTERN: @@ -61,7 +63,7 @@ func NewTagHandler(_options ...options.WithOption) TagHandler { func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { if match := utils.GetSubgroupMatches(m.digitalPattern, tagAddress); match != nil { - address, quantity, err := parseAddressAndQuantity(match, maxDigitalPin) + address, quantity, explicitRange, err := parseAddressAndQuantity(match, tagAddress, maxDigitalPin) if err != nil { return nil, err } @@ -70,16 +72,19 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { pullup := readWriteModel.PinMode_PinModePullup pinMode = &pullup } - return NewDigitalTag(address, quantity, pinMode), nil + return NewDigitalTagWithShape(address, quantity, pinMode, explicitRange), nil } if match := utils.GetSubgroupMatches(m.analogPattern, tagAddress); match != nil { - address, quantity, err := parseAddressAndQuantity(match, maxAnalogPin) + address, quantity, explicitRange, err := parseAddressAndQuantity(match, tagAddress, maxAnalogPin) if err != nil { return nil, err } - return NewAnalogTag(address, quantity), nil + return NewAnalogTagWithShape(address, quantity, explicitRange), nil } - return nil, errors.Errorf("Unable to parse %s", tagAddress) + // "digital:2[4]" still parses, but it now selects the pin at index 4 rather than four pins; + // a form that no longer parses at all gets the shape it should have been written in. + return nil, spiModel.InvalidAddressError(tagAddress, + "digital:{pin}[selection] or analog:{pin}[selection] - for example digital:2[0..3]") } // ParseQuery is not supported: firmata boards can be asked for their capabilities, but neither this @@ -91,28 +96,34 @@ func (m TagHandler) ParseQuery(_ string) (apiModel.PlcQuery, error) { // parseAddressAndQuantity turns the numbers out of an address into a pin and a run length, refusing // runs which reach past the last pin the wire format can address, since a pin beyond that one is // silently truncated to something else when it goes onto the wire. -func parseAddressAndQuantity(match map[string]string, maxPin uint64) (uint8, uint8, error) { - address, err := strconv.ParseUint(match["address"], 10, 32) +// +// The selection's offset moves the pin, so "digital:2[4..7]" is the same run as "digital:6[0..3]". +// A firmata run is consecutive pins, so nothing deeper than a single dimension fits. +func parseAddressAndQuantity(match map[string]string, address string, maxPin uint64) (uint8, uint8, bool, error) { + pin, err := strconv.ParseUint(match["address"], 10, 32) if err != nil { - return 0, 0, errors.Wrapf(err, "Error parsing address %s", match["address"]) + return 0, 0, false, errors.Wrapf(err, "Error parsing address %s", match["address"]) } quantity := uint64(1) - if quantityString := match["quantity"]; quantityString != "" { - if quantity, err = strconv.ParseUint(quantityString, 10, 32); err != nil { - return 0, 0, errors.Wrapf(err, "Error parsing quantity %s", quantityString) + explicitRange := false + if expression := match["array"]; expression != "" { + dimensions, err := spiModel.ParseArrayExpression(expression, address, spiModel.SingleDimension) + if err != nil { + return 0, 0, false, err } - } - if quantity == 0 { - return 0, 0, errors.New("quantity must be greater than zero") + pin += uint64(dimensions[0].GetLowerBound() - dimensions[0].GetBase()) + quantity = uint64(dimensions[0].GetSize()) + // A range is an array even when it spans one pin, which the count cannot say. + explicitRange = dimensions[0].IsRange() } if quantity > maxQuantity { - return 0, 0, errors.Errorf("quantity may not be larger than %d. Was %d", maxQuantity, quantity) + return 0, 0, false, errors.Errorf("quantity may not be larger than %d. Was %d", maxQuantity, quantity) } - if address > maxPin { - return 0, 0, errors.Errorf("pin %d is out of range, the highest addressable pin is %d", address, maxPin) + if pin > maxPin { + return 0, 0, false, errors.Errorf("pin %d is out of range, the highest addressable pin is %d", pin, maxPin) } - if address+quantity-1 > maxPin { - return 0, 0, errors.Errorf("a run of %d pins starting at pin %d reaches past the highest addressable pin %d", quantity, address, maxPin) + if pin+quantity-1 > maxPin { + return 0, 0, false, errors.Errorf("a run of %d pins starting at pin %d reaches past the highest addressable pin %d", quantity, pin, maxPin) } - return uint8(address), uint8(quantity), nil + return uint8(pin), uint8(quantity), explicitRange, nil } diff --git a/plc4go/internal/firmata/TagHandler_test.go b/plc4go/internal/firmata/TagHandler_test.go index 3a5e5a8cdea..bb1276b2b6d 100644 --- a/plc4go/internal/firmata/TagHandler_test.go +++ b/plc4go/internal/firmata/TagHandler_test.go @@ -48,14 +48,14 @@ func TestTagHandler_ParseTag(t *testing.T) { }, { name: "a run of digital pins", - tagAddress: "digital:4[3]", - want: digitalTag{address: 4, quantity: 3}, + tagAddress: "digital:4[0..2]", + want: digitalTag{address: 4, quantity: 3, explicitRange: true}, wantValue: apiValues.BOOL, - wantAddress: "digital:4[3]", + wantAddress: "digital:4[0..2]", }, { name: "a run of one digital pin is a scalar", - tagAddress: "digital:4[1]", + tagAddress: "digital:4", want: digitalTag{address: 4, quantity: 1}, wantValue: apiValues.BOOL, wantAddress: "digital:4", @@ -69,10 +69,10 @@ func TestTagHandler_ParseTag(t *testing.T) { }, { name: "a run of pullup digital pins", - tagAddress: "digital:7[2]:PULLUP", - want: digitalTag{address: 7, quantity: 2, pinMode: &pullup}, + tagAddress: "digital:7[0..1]:PULLUP", + want: digitalTag{address: 7, quantity: 2, pinMode: &pullup, explicitRange: true}, wantValue: apiValues.BOOL, - wantAddress: "digital:7[2]:PULLUP", + wantAddress: "digital:7[0..1]:PULLUP", }, { name: "the last addressable digital pin", @@ -90,10 +90,10 @@ func TestTagHandler_ParseTag(t *testing.T) { }, { name: "a run of analog pins", - tagAddress: "analog:2[4]", - want: analogTag{address: 2, quantity: 4}, + tagAddress: "analog:2[0..3]", + want: analogTag{address: 2, quantity: 4, explicitRange: true}, wantValue: apiValues.INT, - wantAddress: "analog:2[4]", + wantAddress: "analog:2[0..3]", }, { name: "the last addressable analog pin", @@ -132,12 +132,14 @@ func TestTagHandler_ParseTagRejects(t *testing.T) { {name: "trailing garbage", tagAddress: "digital:4nonsense"}, {name: "an unknown mode", tagAddress: "digital:4:OUTPUT"}, {name: "a mode on an analog pin", tagAddress: "analog:4:PULLUP"}, - {name: "a quantity of zero", tagAddress: "digital:4[0]"}, + // A run of zero pins has no spelling in the notation - a range is written with the + // indices it covers - but an inverted one is still nonsense. + {name: "an inverted range", tagAddress: "digital:4[3..1]"}, {name: "a digital pin past the last port", tagAddress: "digital:128"}, - {name: "a run of digital pins past the last port", tagAddress: "digital:126[4]"}, + {name: "a run of digital pins past the last port", tagAddress: "digital:126[0..3]"}, {name: "an analog pin past the 4 bit pin field", tagAddress: "analog:16"}, - {name: "a run of analog pins past the 4 bit pin field", tagAddress: "analog:14[4]"}, - {name: "a quantity larger than the whole pin range", tagAddress: "digital:0[129]"}, + {name: "a run of analog pins past the 4 bit pin field", tagAddress: "analog:14[0..3]"}, + {name: "a quantity larger than the whole pin range", tagAddress: "digital:0[0..128]"}, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { @@ -161,11 +163,13 @@ func TestTag_ArrayInfoAndSubscriptionDefaults(t *testing.T) { require.NoError(t, err) assert.Empty(t, scalar.GetArrayInfo()) - list, err := NewTagHandler().ParseTag("digital:4[3]") + list, err := NewTagHandler().ParseTag("digital:4[0..2]") require.NoError(t, err) require.Len(t, list.GetArrayInfo(), 1) assert.Equal(t, uint32(0), list.GetArrayInfo()[0].GetLowerBound()) - assert.Equal(t, uint32(3), list.GetArrayInfo()[0].GetUpperBound()) + // Both bounds are inclusive, so three pins are 0..2. + assert.Equal(t, uint32(2), list.GetArrayInfo()[0].GetUpperBound()) + assert.Equal(t, uint32(3), list.GetArrayInfo()[0].GetSize()) // A tag has to be usable in a subscription request, which only accepts tags that are a // PlcSubscriptionTag. Firmata boards report a pin when it changes. @@ -180,7 +184,7 @@ func TestTag_ArrayInfoAndSubscriptionDefaults(t *testing.T) { } func TestTag_String(t *testing.T) { - digital, err := NewTagHandler().ParseTag("digital:4[2]:PULLUP") + digital, err := NewTagHandler().ParseTag("digital:4[0..1]:PULLUP") require.NoError(t, err) assert.Contains(t, digital.String(), "PinModePullup") @@ -188,3 +192,32 @@ func TestTag_String(t *testing.T) { require.NoError(t, err) assert.Contains(t, analog.String(), "analogTag") } + +// A firmata address is a pin number, so a selection that starts past the declared base is +// resolved into the pin: "digital:2[4..7]" is the same run as "digital:6[0..3]". +// +// [n] used to mean "n pins" and now means "the pin at index n". Both forms parse, so nothing can +// be rejected here - this is one of the two silent changes the release notes have to carry. +func TestTagHandler_ParseTag_consumesTheSelectionOffset(t *testing.T) { + handler := NewTagHandler() + + shifted, err := handler.ParseTag("digital:2[4..7]") + require.NoError(t, err) + equivalent, err := handler.ParseTag("digital:6[0..3]") + require.NoError(t, err) + assert.Equal(t, equivalent, shifted) + assert.Equal(t, "digital:6[0..3]", shifted.GetAddressString()) + + // A declared base is what the offset is measured from, so [4..7;4] shifts nothing. + fromDeclaredBase, err := handler.ParseTag("digital:2[4..7;4]") + require.NoError(t, err) + unshifted, err := handler.ParseTag("digital:2[0..3]") + require.NoError(t, err) + assert.Equal(t, unshifted, fromDeclaredBase) + + // The silent change: [3] is the pin at index 3, which is pin 5 here - not three pins. + single, err := handler.ParseTag("digital:2[3]") + require.NoError(t, err) + assert.Equal(t, "digital:5", single.GetAddressString()) + assert.Empty(t, single.GetArrayInfo(), "one pin is a scalar") +} diff --git a/plc4go/internal/firmata/Writer_test.go b/plc4go/internal/firmata/Writer_test.go index 7f0e85084df..038868c6015 100644 --- a/plc4go/internal/firmata/Writer_test.go +++ b/plc4go/internal/firmata/Writer_test.go @@ -88,7 +88,7 @@ func TestWriter_WriteRepeatsNoPinMode(t *testing.T) { func TestWriter_WriteARunOfDigitalPins(t *testing.T) { connection, transportInstance := newTestConnection(t) - result := executeWrite(t, connection, "bar", "digital:2[3]", []bool{true, false, true}) + result := executeWrite(t, connection, "bar", "digital:2[0..2]", []bool{true, false, true}) require.NoError(t, result.GetErr()) assert.Equal(t, apiModel.PlcResponseCode_OK, result.GetResponse().GetResponseCode("bar")) @@ -105,7 +105,7 @@ func TestWriter_WriteRejectsTheWrongNumberOfValues(t *testing.T) { connection, transportInstance := newTestConnection(t) writeRequestBuilder := connection.WriteRequestBuilder() - writeRequestBuilder.AddTagAddress("bar", "digital:2[3]", []bool{true, false}) + writeRequestBuilder.AddTagAddress("bar", "digital:2[0..2]", []bool{true, false}) _, err := writeRequestBuilder.Build() assert.Error(t, err) assert.Empty(t, sentBytes(t, transportInstance), "a rejected write must not reconfigure any pin") @@ -192,7 +192,7 @@ func TestWriter_WriteClaimsARunAllOrNothing(t *testing.T) { require.Equal(t, apiModel.PlcResponseCode_OK, responseCode) sentBytes(t, transportInstance) - result := executeWrite(t, connection, "bar", "digital:2[3]", []bool{true, true, true}) + result := executeWrite(t, connection, "bar", "digital:2[0..2]", []bool{true, true, true}) require.NoError(t, result.GetErr()) assert.Equal(t, apiModel.PlcResponseCode_INVALID_ADDRESS, result.GetResponse().GetResponseCode("bar")) assert.Empty(t, sentBytes(t, transportInstance)) diff --git a/plc4go/internal/knxnetip/Browser.go b/plc4go/internal/knxnetip/Browser.go index 5e20effcdc8..4c31f921ef7 100644 --- a/plc4go/internal/knxnetip/Browser.go +++ b/plc4go/internal/knxnetip/Browser.go @@ -231,7 +231,7 @@ func (m Browser) executeCommunicationObjectQuery(ctx context.Context, query Comm // Read the data in the group address table readRequest, err = m.connection.ReadRequestBuilder(). AddTagAddress("groupAddressTable", - fmt.Sprintf("%s#%X:UINT[%d]", knxAddressString, groupAddressTableStartAddress, numGroupAddresses)). + fmt.Sprintf("%s#%X[0..%d]:UINT", knxAddressString, groupAddressTableStartAddress, numGroupAddresses-1)). Build() if err != nil { return nil, errors.Wrap(err, "error creating read request") @@ -316,10 +316,10 @@ func (m Browser) executeCommunicationObjectQuery(ctx context.Context, query Comm // - Max 63 bytes readable in one request, due to max of count tag if m.connection.DeviceConnections[knxAddress].deviceDescriptor == uint16(0x07B0) /* SystemB */ { readRequestBuilder.AddTagAddress("groupAddressAssociationTable", - fmt.Sprintf("%s#%X:UDINT[%d]", knxAddressString, groupAddressAssociationTableAddress+2, numberOfGroupAddressAssociationTableEntries)) + fmt.Sprintf("%s#%X[0..%d]:UDINT", knxAddressString, groupAddressAssociationTableAddress+2, numberOfGroupAddressAssociationTableEntries-1)) } else { readRequestBuilder.AddTagAddress("groupAddressAssociationTable", - fmt.Sprintf("%s#%X:UINT[%d]", knxAddressString, groupAddressAssociationTableAddress+1, numberOfGroupAddressAssociationTableEntries)) + fmt.Sprintf("%s#%X[0..%d]:UINT", knxAddressString, groupAddressAssociationTableAddress+1, numberOfGroupAddressAssociationTableEntries-1)) } readRequest, err = readRequestBuilder.Build() if err != nil { @@ -463,7 +463,7 @@ func (m Browser) executeCommunicationObjectQuery(ctx context.Context, query Comm groupAddressMap[comObjectNumber] = append(groupAddressMap[comObjectNumber], groupAddress) entryAddress := comObjectTableAddresses.ComObjectTableAddress() + 3 + (comObjectNumber * 4) readRequestBuilder.AddTagAddress(strconv.Itoa(int(comObjectNumber)), - fmt.Sprintf("%s#%X:USINT[4]", knxAddressString, entryAddress)) + fmt.Sprintf("%s#%X[0..3]:USINT", knxAddressString, entryAddress)) } readRequest, err = readRequestBuilder.Build() if err != nil { diff --git a/plc4go/internal/knxnetip/Subscriber.go b/plc4go/internal/knxnetip/Subscriber.go index 0f0a305d103..7ee2e016023 100644 --- a/plc4go/internal/knxnetip/Subscriber.go +++ b/plc4go/internal/knxnetip/Subscriber.go @@ -280,7 +280,9 @@ func (s *Subscriber) handleValueChange(ctx context.Context, destinationAddress [ elementType := *groupAddressTag.GetTagType() numElements := uint16(1) if len(groupAddressTag.GetArrayInfo()) > 0 { - numElements = uint16(groupAddressTag.GetArrayInfo()[0].GetUpperBound() - groupAddressTag.GetArrayInfo()[0].GetLowerBound()) + // GetSize, not upper minus lower: both bounds are inclusive, so subtracting them + // decodes one element short of what was asked for - [0..3] would yield three. + numElements = uint16(groupAddressTag.GetArrayInfo()[0].GetSize()) } tags[tagName] = groupAddressTag diff --git a/plc4go/internal/knxnetip/Tag.go b/plc4go/internal/knxnetip/Tag.go index 3c942f10fc5..df440b69048 100644 --- a/plc4go/internal/knxnetip/Tag.go +++ b/plc4go/internal/knxnetip/Tag.go @@ -29,6 +29,7 @@ import ( "github.com/apache/plc4x/plc4go/pkg/api/values" driverModel "github.com/apache/plc4x/plc4go/protocols/knxnetip/readwrite/model" "github.com/apache/plc4x/plc4go/spi/errors" + spiModel "github.com/apache/plc4x/plc4go/spi/model" ) type Tag interface { @@ -302,16 +303,20 @@ func NewDevicePropertyAddressPlcTag(mainGroup uint8, middleGroup uint8, subGroup } func (k DevicePropertyAddressPlcTag) GetAddressString() string { - return fmt.Sprintf("%d/%d/%d#%d/%d/%d[%d]", - k.MainGroup, k.MiddleGroup, k.SubGroup, k.ObjectId, k.PropertyId, k.PropertyIndex, k.NumElements) + return fmt.Sprintf("%d.%d.%d#%d/%d/%d%s", + k.MainGroup, k.MiddleGroup, k.SubGroup, k.ObjectId, k.PropertyId, k.PropertyIndex, + spiModel.RenderArrayExpression(k.GetArrayInfo())) } func (k DevicePropertyAddressPlcTag) GetValueType() values.PlcValueType { return values.Struct } +// GetArrayInfo reports the shape of the value the caller receives: a run of property elements is +// a list, a single one is a scalar. The indices are relative to the value, not to the address - +// the start of the selection is folded into the property index when the address is resolved. func (k DevicePropertyAddressPlcTag) GetArrayInfo() []apiModel.ArrayInfo { - return []apiModel.ArrayInfo{} + return elementsAsArrayInfo(k.NumElements) } func (k DevicePropertyAddressPlcTag) toKnxAddress() driverModel.KnxAddress { @@ -344,8 +349,9 @@ func NewDeviceMemoryAddressPlcTag(mainGroup uint8, middleGroup uint8, subGroup u } func (k DeviceMemoryAddressPlcTag) GetAddressString() string { - return fmt.Sprintf("%d/%d/%d#%d:%s[%d]", - k.MainGroup, k.MiddleGroup, k.SubGroup, k.Address, k.TagType.String(), k.NumElements) + return fmt.Sprintf("%d.%d.%d#%X%s:%s", + k.MainGroup, k.MiddleGroup, k.SubGroup, k.Address, + spiModel.RenderArrayExpression(k.GetArrayInfo()), k.TagType.String()) } func (k DeviceMemoryAddressPlcTag) GetValueType() values.PlcValueType { @@ -353,6 +359,21 @@ func (k DeviceMemoryAddressPlcTag) GetValueType() values.PlcValueType { } func (k DeviceMemoryAddressPlcTag) GetArrayInfo() []apiModel.ArrayInfo { + return elementsAsArrayInfo(k.NumElements) +} + +// elementsAsArrayInfo is the shape a count describes: a list for more than one element, a scalar +// for exactly one. +func elementsAsArrayInfo(numElements uint8) []apiModel.ArrayInfo { + if numElements > 1 { + return []apiModel.ArrayInfo{ + &spiModel.DefaultArrayInfo{ + LowerBound: 0, + UpperBound: uint32(numElements) - 1, + Range: true, + }, + } + } return []apiModel.ArrayInfo{} } diff --git a/plc4go/internal/knxnetip/TagHandler.go b/plc4go/internal/knxnetip/TagHandler.go index c3fa56b2c14..64a8687f871 100644 --- a/plc4go/internal/knxnetip/TagHandler.go +++ b/plc4go/internal/knxnetip/TagHandler.go @@ -27,6 +27,7 @@ import ( apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" driverModel "github.com/apache/plc4x/plc4go/protocols/knxnetip/readwrite/model" "github.com/apache/plc4x/plc4go/spi/errors" + spiModel "github.com/apache/plc4x/plc4go/spi/model" "github.com/apache/plc4x/plc4go/spi/utils" ) @@ -46,13 +47,39 @@ func NewTagHandler() TagHandler { groupAddress2Level: regexp.MustCompile(`^(?P(\d{1,2}|\*|\[(\d{1,2}|\d{1,2}\-\d{1,2})(,(\d{1,2}|\d{1,2}\-\d{1,2}))*]))/(?P(\d{1,4}|\*|\[(\d{1,4}|\d{1,4}\-\d{1,4})(,(\d{1,4}|\d{1,4}\-\d{1,4}))*]))(:(?P[a-zA-Z_]+))?$`), groupAddress1Level: regexp.MustCompile(`^(?P(\d{1,5}|\*|\[(\d{1,5}|\d{1,5}\-\d{1,5})(,(\d{1,5}|\d{1,5}\-\d{1,5}))*]))(:(?P[a-zA-Z_]+))?$`), - deviceQuery: regexp.MustCompile(`^(?P(\d{1,2}|\*|\[(\d{1,2}|\d{1,2}\-\d{1,2})(,(\d{1,2}|\d{1,2}\-\d{1,2}))*]))\.(?P(\d{1,2}|\*|\[(\d{1,2}|\d{1,2}\-\d{1,2})(,(\d{1,2}|\d{1,2}\-\d{1,2}))*]))\.(?P(\d{1,3}|\*|\[(\d{1,3}|\d{1,3}\-\d{1,3})(,(\d{1,3}|\d{1,3}\-\d{1,3}))*]))$`), - devicePropertyAddress: regexp.MustCompile(`^(?P\d{1,2})\.(?P\d)\.(?P\d{1,3})#(?P\d{1,3})\/(?P\d{1,3})(\/(?P\d{1,4}))?(\[(?P\d{1,2})])?$`), - deviceMemoryAddress: regexp.MustCompile(`^(?P\d{1,2})\.(?P\d)\.(?P\d{1,3})#(?P
[0-9a-fA-F]{1,8})(:(?P[a-zA-Z_]+)(\[(?P\d+)])?)?$`), + deviceQuery: regexp.MustCompile(`^(?P(\d{1,2}|\*|\[(\d{1,2}|\d{1,2}\-\d{1,2})(,(\d{1,2}|\d{1,2}\-\d{1,2}))*]))\.(?P(\d{1,2}|\*|\[(\d{1,2}|\d{1,2}\-\d{1,2})(,(\d{1,2}|\d{1,2}\-\d{1,2}))*]))\.(?P(\d{1,3}|\*|\[(\d{1,3}|\d{1,3}\-\d{1,3})(,(\d{1,3}|\d{1,3}\-\d{1,3}))*]))$`), + // The two device forms carry a real element count, so they take the shared notation. + // A property address has no type suffix, so the selection ends it. + devicePropertyAddress: regexp.MustCompile(`^(?P\d{1,2})\.(?P\d)\.(?P\d{1,3})#(?P\d{1,3})\/(?P\d{1,3})(\/(?P\d{1,4}))?` + spiModel.ArrayGroupPattern + `$`), + deviceMemoryAddress: regexp.MustCompile(`^(?P\d{1,2})\.(?P\d)\.(?P\d{1,3})#(?P
[0-9a-fA-F]{1,8})` + spiModel.ArrayGroupPattern + `(:(?P[a-zA-Z_]+))?$`), deviceCommunicationObjectQuery: regexp.MustCompile(`^(?P\d{1,2})\.(?P\d)\.(?P\d{1,3})#com-obj$`), } } +// selectionOf reads the array expression a device address carries and returns how far past the +// written start the selection begins and how many elements it spans. An address with no +// expression reads one element where it says. +// +// The group-address forms are deliberately not routed through here: their brackets hold a set of +// group addresses to match - "[1-3,5]" - not an array selection, and they never described a +// count. +func selectionOf(expression string, tagAddress string) (uint64, uint64, error) { + if expression == "" { + return 0, 1, nil + } + dimensions, err := spiModel.ParseArrayExpression(expression, tagAddress, spiModel.SingleDimension) + if err != nil { + return 0, 0, err + } + dimension := dimensions[0] + elements := uint64(dimension.GetSize()) + if elements > 0xFF { + return 0, 0, errors.Errorf("A selection of %d elements in '%s' is more than the count "+ + "field can carry", elements, tagAddress) + } + return uint64(dimension.GetLowerBound() - dimension.GetBase()), elements, nil +} + func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { if match := utils.GetSubgroupMatches(m.groupAddress1Level, tagAddress); match != nil { tagTypeName, ok := match["datatype"] @@ -95,11 +122,14 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { if ok && len(propertyInd) > 0 { propertyIndex, _ = strconv.ParseUint(propertyInd, 10, 16) } - numberOfElements := uint64(1) - numElements, ok := match["numElements"] - if ok && len(numElements) > 0 { - numberOfElements, _ = strconv.ParseUint(numElements, 10, 8) + // A property is read with a start index and a count, and the property index written in + // the address is that start index - so a selection that starts past the first element + // moves it, exactly as an offset moves the address of a memory-addressed driver. + offset, numberOfElements, err := selectionOf(match["array"], tagAddress) + if err != nil { + return nil, err } + propertyIndex += offset return NewDevicePropertyAddressPlcTag( uint8(mainGroup), uint8(middleGroup), uint8(subGroup), uint8(objectId), uint8(propertyId), uint16(propertyIndex), uint8(numberOfElements)), nil @@ -122,10 +152,18 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { } else { return nil, errors.New("invalid address: " + match["address"]) } - numberOfElements := uint64(1) - numElements, ok := match["numElements"] - if ok && len(numElements) > 0 { - numberOfElements, _ = strconv.ParseUint(numElements, 10, 8) + offset, numberOfElements, err := selectionOf(match["array"], tagAddress) + if err != nil { + return nil, err + } + if offset != 0 { + // Unlike the property form there is nothing exact to move here: what one element + // occupies depends on the datapoint type, which is measured in bits and need not be + // a whole number of bytes. Guessing a byte size would move the address to somewhere + // no one asked for, so this is reported instead. + return nil, errors.Errorf("Array selection in tag '%s' must start at the first "+ + "element: a memory address is moved in bytes and a datapoint type is measured "+ + "in bits, so there is no offset to apply", tagAddress) } return NewDeviceMemoryAddressPlcTag(uint8(mainGroup), uint8(middleGroup), uint8(subGroup), address, uint8(numberOfElements), &tagType), nil } else if match := utils.GetSubgroupMatches(m.deviceCommunicationObjectQuery, tagAddress); match != nil { @@ -135,7 +173,9 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { return NewCommunicationObjectQuery( uint8(mainGroup), uint8(middleGroup), uint8(subGroup)), nil } - return nil, errors.New("Invalid address format for tag '" + tagAddress + "'") + return nil, spiModel.InvalidAddressError(tagAddress, + "a group address, or {area}.{line}.{device}#{object}/{property}[selection] - "+ + "for example 1.2.3#11/1[0..3]") } func (m TagHandler) ParseQuery(query string) (apiModel.PlcQuery, error) { diff --git a/plc4go/internal/knxnetip/TagHandler_test.go b/plc4go/internal/knxnetip/TagHandler_test.go index c4fc9333382..9fa8a22b978 100644 --- a/plc4go/internal/knxnetip/TagHandler_test.go +++ b/plc4go/internal/knxnetip/TagHandler_test.go @@ -138,3 +138,66 @@ func Test_Connection_BrowseRequestBuilder_acceptsBothQueryForms(t *testing.T) { assert.IsType(t, DeviceQuery{}, browseRequest.GetQuery("devices")) assert.IsType(t, CommunicationObjectQuery{}, browseRequest.GetQuery("comObjects")) } + +// The two device address forms carry a real element count, so they take the shared notation. +// Nothing here covered them before, which is how the browser came to build addresses in a +// spelling the handler no longer accepts - a failure that only showed up against a device. +func TestTagHandler_DeviceAddressesUseTheSharedNotation(t *testing.T) { + handler := NewTagHandler() + + for _, address := range []string{ + "1.2.3#4B1C:UINT", // a memory address, one element + "1.2.3#4B1C[0..7]:UINT", // eight of them, the selection before the type + "1.2.3#4B1C[0..3]:USINT", // the form the browser builds when it walks the tables + "1.2.3#11/1/5[0..3]", // a property address; no type suffix, so the selection ends it + "1.2.3#3/23/5", // a property address with no selection + } { + t.Run(address, func(t *testing.T) { + tag, err := handler.ParseTag(address) + require.NoError(t, err) + assert.Equal(t, address, tag.GetAddressString()) + + reparsed, err := handler.ParseTag(tag.GetAddressString()) + require.NoError(t, err) + assert.Equal(t, tag, reparsed) + }) + } + + // An omitted property index defaults to 1 and is spelled out when the tag is rendered, which + // still re-parses to the same tag. + implied, err := handler.ParseTag("1.2.3#11/1[0..3]") + require.NoError(t, err) + assert.Equal(t, "1.2.3#11/1/1[0..3]", implied.GetAddressString()) + reparsed, err := handler.ParseTag(implied.GetAddressString()) + require.NoError(t, err) + assert.Equal(t, implied, reparsed) +} + +// A property is read with a start index and a count, and the property index in the address is +// that start index - so a selection that starts past the first element moves it. +func TestTagHandler_APropertySelectionMovesTheStartIndex(t *testing.T) { + handler := NewTagHandler() + + shifted, err := handler.ParseTag("1.2.3#11/1/1[4..7]") + require.NoError(t, err) + equivalent, err := handler.ParseTag("1.2.3#11/1/5[0..3]") + require.NoError(t, err) + assert.Equal(t, equivalent, shifted) + + // A memory address has no such mapping: what one element occupies depends on the datapoint + // type, which is measured in bits, so an offset is reported rather than guessed at. + _, err = handler.ParseTag("1.2.3#4B1C[4..7]:UINT") + require.Error(t, err) + assert.Contains(t, err.Error(), "must start at the first element") +} + +// The group-address forms are not array addresses at all: their brackets hold a set of group +// addresses to match, so they are left exactly as they were. +func TestTagHandler_GroupAddressBracketsAreAMatchExpression(t *testing.T) { + handler := NewTagHandler() + + for _, address := range []string{"1/[2-3]/4:BOOL", "[1,3]/2/[4-6]:BOOL", "*/*/*:BOOL"} { + _, err := handler.ParseTag(address) + assert.NoError(t, err, address) + } +} diff --git a/plc4go/internal/modbus/Connection_test.go b/plc4go/internal/modbus/Connection_test.go index 2804250c98e..732f2685910 100644 --- a/plc4go/internal/modbus/Connection_test.go +++ b/plc4go/internal/modbus/Connection_test.go @@ -178,7 +178,7 @@ func TestConnection_pingFailsOnAnUnusablePingAddress(t *testing.T) { }{ {"unparsable", "this is not an address"}, {"an address outside the address space", "holding-register:70000:INT"}, - {"a quantity no request can carry", "holding-register:1:INT[126]"}, + {"a quantity no request can carry", "holding-register:1[0..125]:INT"}, } { t.Run(test.name, func(t *testing.T) { configuration := DefaultConfiguration() diff --git a/plc4go/internal/modbus/RegisterCodec_test.go b/plc4go/internal/modbus/RegisterCodec_test.go index 1382d1397b4..f5bbcf5d69b 100644 --- a/plc4go/internal/modbus/RegisterCodec_test.go +++ b/plc4go/internal/modbus/RegisterCodec_test.go @@ -268,7 +268,7 @@ func TestPacksSeveralStrings(t *testing.T) { // An odd number of strings must not pick up a pad byte: a string is padded to a whole register by // its own declared length, not by the single character the element width used to be computed from. -// With the pad byte in place a 'holding-register:1:STRING(20)[3]' write serialized to 61 bytes +// With the pad byte in place a 'holding-register:1[0..2]:STRING(20)' write serialized to 61 bytes // while the read side asked for 30 registers. func TestPacksAnOddNumberOfStringsWithoutAPadByte(t *testing.T) { three := values.NewPlcList([]apiValues.PlcValue{ diff --git a/plc4go/internal/modbus/Tag.go b/plc4go/internal/modbus/Tag.go index ea988ec8c87..596e57bf176 100644 --- a/plc4go/internal/modbus/Tag.go +++ b/plc4go/internal/modbus/Tag.go @@ -23,6 +23,7 @@ import ( "context" "encoding/binary" "fmt" + "math" "strconv" "strings" @@ -60,7 +61,10 @@ type modbusTag struct { TagType TagType Address uint16 Quantity uint16 - Datatype readWriteModel.ModbusDataType + // ExplicitRange records whether the address wrote the selection as a range. A one-element + // range is still a range - [4] is a scalar and [4..4] a list of one - which no count can say. + ExplicitRange bool + Datatype readWriteModel.ModbusDataType // StringLength is the declared length of a single string. Nothing on the wire announces it, so // it is part of the address; for every data type that is not a string it is 1, which leaves the // size arithmetic unchanged (plc4j ModbusTag). @@ -88,18 +92,19 @@ func logicalAddressOffset(tagType TagType) uint16 { // NewTag builds a tag from the logical (user facing) address, which for every area but the // extended registers is one higher than the address that goes onto the wire. func NewTag(tagType TagType, address uint16, quantity uint16, datatype readWriteModel.ModbusDataType) apiModel.PlcTag { - return newTagFromWireAddress(tagType, address-logicalAddressOffset(tagType), quantity, datatype, 1, tagConfig{}) + return newTagFromWireAddress(tagType, address-logicalAddressOffset(tagType), quantity, datatype, 1, tagConfig{}, quantity > 1) } -func newTagFromWireAddress(tagType TagType, wireAddress uint16, quantity uint16, datatype readWriteModel.ModbusDataType, stringLength uint16, config tagConfig) modbusTag { +func newTagFromWireAddress(tagType TagType, wireAddress uint16, quantity uint16, datatype readWriteModel.ModbusDataType, stringLength uint16, config tagConfig, explicitRange bool) modbusTag { return modbusTag{ - TagType: tagType, - Address: wireAddress, - Quantity: quantity, - Datatype: datatype, - StringLength: stringLength, - UnitId: config.unitId, - ByteOrder: config.byteOrder, + ExplicitRange: explicitRange, + TagType: tagType, + Address: wireAddress, + Quantity: quantity, + Datatype: datatype, + StringLength: stringLength, + UnitId: config.unitId, + ByteOrder: config.byteOrder, } } @@ -179,8 +184,10 @@ func (m modbusTag) resolveByteOrder(defaultByteOrder ByteOrder) ByteOrder { // ModbusTagExtendedRegister.of) reject an address beyond the 16 bit address space, a range running // past the end of it and a quantity beyond what a single request can carry - 2000 for the bit // areas, 125 for the register areas. -// Both arguments are the values as written by the user, i.e. address is the logical address. -func validateAddressAndQuantity(tagType TagType, address uint64, quantity uint64) error { +// address is the logical address as written by the user and quantity the number of elements the +// user selected; registers is what those elements occupy on the wire, which is what the address +// space and the per-request ceilings are measured in. +func validateAddressAndQuantity(tagType TagType, address uint64, quantity uint64, registers uint64) error { // plc4j checks getLogicalAddress() <= 0 in the ModbusTag constructor, which covers the // extended register area too even though that one is addressed starting at zero on the wire. if address < 1 { @@ -196,49 +203,125 @@ func validateAddressAndQuantity(tagType TagType, address uint64, quantity uint64 } // plc4j rejects a range whose last address reaches maxWireAddress, and the strict bound keeps // the quantity well inside the 16 bit field it is written into further down. - if wireAddress+quantity > maxWireAddress { + if wireAddress+registers > maxWireAddress { return errors.Errorf("last requested address is out of range, should be between %d and %d. Was %d", - offset, maxWireAddress, wireAddress+quantity) + offset, maxWireAddress, wireAddress+registers) } switch tagType { case Coil, DiscreteInput: - if quantity > maxCoilQuantity { - return errors.Errorf("quantity may not be larger than %d. Was %d", maxCoilQuantity, quantity) + if registers > maxCoilQuantity { + return errors.Errorf("quantity may not be larger than %d. Was %d", maxCoilQuantity, registers) } default: - if quantity > maxRegisterQuantity { - return errors.Errorf("quantity may not be larger than %d. Was %d", maxRegisterQuantity, quantity) + if registers > maxRegisterQuantity { + return errors.Errorf("quantity may not be larger than %d registers. Was %d", maxRegisterQuantity, registers) } } return nil } -func NewModbusPlcTagFromStrings(tagType TagType, addressString string, quantityString string, stringLengthString string, datatype readWriteModel.ModbusDataType, config tagConfig, _options ...options.WithOption) (apiModel.PlcTag, error) { +// modbusConstraints: a Modbus read covers one contiguous run of registers or bits, so an address +// selects from a single dimension. +var modbusConstraints = spiModel.SingleDimension + +// selectionOf reads the array expression an address carries and returns how far past the written +// address the selection starts, in registers, and how many it spans. An address with no +// expression reads one element where it says. +// +// The offset is consumed into the address here, the way plc4j's per-area of() does: a Modbus +// address is a register number, so "holding-register:1[4..7]" is the same read as +// "holding-register:5[0..3]". +func selectionOf(expression string, address string) (uint64, uint64, bool, error) { + if expression == "" { + return 0, 1, false, nil + } + dimensions, err := spiModel.ParseArrayExpression(expression, address, modbusConstraints) + if err != nil { + return 0, 0, false, err + } + dimension := dimensions[0] + // The third value is not derivable from the others: [4] and [4..4] both select one element, + // and only the range is an array. + return uint64(dimension.GetLowerBound() - dimension.GetBase()), uint64(dimension.GetSize()), + dimension.IsRange(), nil +} + +func NewModbusPlcTagFromStrings(tagType TagType, addressString string, arrayExpression string, stringLengthString string, datatype readWriteModel.ModbusDataType, config tagConfig, _options ...options.WithOption) (apiModel.PlcTag, error) { // Parsed with 32 bits so that an out-of-range address is reported as such instead of as an // unparsable string. address, err := strconv.ParseUint(addressString, 10, 32) if err != nil { return nil, errors.Errorf("Couldn't parse address string '%s' into an int", addressString) } - customLogger := options.ExtractCustomLoggerOrDefaultToGlobal(_options...) - if quantityString == "" { - customLogger.Debug().Msg("No quantity supplied, assuming 1") - quantityString = "1" - } - quantity, err := strconv.ParseUint(quantityString, 10, 32) + offset, quantity, explicitRange, err := selectionOf(arrayExpression, addressString+arrayExpression) if err != nil { - // A quantity that was spelled out but doesn't parse is a broken address, not a request - // for a single element. - return nil, errors.Errorf("Couldn't parse quantity string '%s' into an int", quantityString) - } - if err := validateAddressAndQuantity(tagType, address, quantity); err != nil { return nil, err } stringLength, err := validateStringLength(datatype, stringLengthString) if err != nil { return nil, err } - return newTagFromWireAddress(tagType, uint16(address-uint64(logicalAddressOffset(tagType))), uint16(quantity), datatype, stringLength, config), nil + // The offset counts elements; a register address counts registers. They are the same number + // only for a one-register type, so "holding-register:1[4]:DINT" would otherwise land four + // registers short of the fifth DINT. + registerOffset, err := registerOffsetOf(tagType, offset, datatype, stringLength) + if err != nil { + return nil, err + } + address += registerOffset + // The wire carries registers, not elements. The address-space and per-request limits are + // about what a request can hold, so they have to be checked against the register count: 63 + // DINTs are 126 registers and do not fit into the 125 a read carries, however few elements + // that is. + registers := registerCountOf(tagType, datatype, quantity, stringLength) + if err := validateAddressAndQuantity(tagType, address, quantity, registers); err != nil { + return nil, err + } + return newTagFromWireAddress(tagType, uint16(address-uint64(logicalAddressOffset(tagType))), uint16(quantity), datatype, stringLength, config, explicitRange), nil +} + +// registerOffsetOf is how far into the area a selection starts, in addresses. +// +// A bit area addresses individual bits, so one element is one address there and nothing is +// scaled. A register area addresses registers, and the conversion goes through the total bit +// offset rather than rounding each element up on its own: the register codec packs elements +// narrower than a register - widthBits reports 8 for a CHAR and 1 for a BOOL - so rounding per +// element would place the start where nothing was written. An offset that does not land on a +// register boundary cannot be addressed by a Modbus read at all, and is reported rather than +// quietly moved to the register before or after it. +func registerOffsetOf(tagType TagType, elementOffset uint64, dataType readWriteModel.ModbusDataType, stringLength uint16) (uint64, error) { + switch tagType { + case Coil, DiscreteInput: + return elementOffset, nil + } + bits := elementOffset * widthBits(dataType, stringLength) + if bits%16 != 0 { + return 0, errors.Errorf("selection starts %d bits into the address, which is not a register "+ + "boundary - a read starts at a register, so this offset cannot be addressed", bits) + } + return bits / 16, nil +} + +// registerCountOf is how many addresses the selection occupies on the wire: bits in a bit area, +// where one element is one address, and registers everywhere else. This is the count the request +// carries, and so the one the address-space and per-request limits apply to. +func registerCountOf(tagType TagType, dataType readWriteModel.ModbusDataType, quantity uint64, stringLength uint16) uint64 { + switch tagType { + case Coil, DiscreteInput: + return quantity + } + if quantity > math.MaxUint16 { + // Too large for lengthInBytes to be asked, and far beyond any request. Reported as-is so + // the quantity limits below reject it rather than a truncated conversion passing. + return quantity + } + registers := (lengthInBytes(dataType, uint16(quantity), stringLength) + 1) / 2 + if registers < 1 { + // A value narrower than a register still occupies a whole one, and every request has to + // ask for something. + return 1 + } + return registers } func (m modbusTag) GetAddressString() string { @@ -249,7 +332,8 @@ func (m modbusTag) GetAddressString() string { } // The logical address is what the user wrote and what the address has to parse back as (plc4j // ModbusTag.getAddressString uses getLogicalAddress for the same reason). - address := fmt.Sprintf("%dx%05d:%s[%d]", m.TagType, uint32(m.Address)+uint32(logicalAddressOffset(m.TagType)), dataType, m.Quantity) + address := fmt.Sprintf("%dx%05d%s:%s", m.TagType, uint32(m.Address)+uint32(logicalAddressOffset(m.TagType)), + spiModel.RenderArrayExpression(m.GetArrayInfo()), dataType) // Same for the per-tag settings, which are written in curly braces behind the address. var config []string if m.UnitId != nil { @@ -272,19 +356,24 @@ func (m modbusTag) GetValueType() apiValues.PlcValueType { } } -// GetArrayInfo reports the number of elements as a half-open range [0, Quantity). +// GetArrayInfo reports the shape of the value the caller receives, as an inclusive range: a +// quantity of 5 yields [0..4], the same as plc4j. +// +// The bounds used to be exclusive here, documented as a deliberate divergence from plc4j. It was +// not one worth keeping: once the range is what the user wrote, the bounds are the indices the +// address stated, and an exclusive upper bound reports a number that appears nowhere in it. // -// Note that plc4j's ModbusTag returns an inclusive upper bound (quantity - 1). The Go SPI uses the -// opposite convention: spiModel.DefaultArrayInfo.GetSize is UpperBound - LowerBound, and every -// consumer (e.g. bacnetip's Reader, knxnetip's Subscriber) as well as every other Go driver (ads, -// s7, cbus, simulated) treats the upper bound as exclusive. Modbus follows the Go convention here, -// so a quantity of 5 yields [0, 5) and not [0, 4]. +// The indices are relative to the value the caller receives, not to what was written: a Modbus +// address is a register number, so the driver consumes the start of the selection into the +// address when it resolves it. func (m modbusTag) GetArrayInfo() []apiModel.ArrayInfo { - if m.Quantity != 1 { + // The flag decides the shape; the count only sizes it. + if m.ExplicitRange { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(m.Quantity), + UpperBound: uint32(m.Quantity) - 1, + Range: true, }, } } diff --git a/plc4go/internal/modbus/TagHandler.go b/plc4go/internal/modbus/TagHandler.go index f4f372eeebc..af244deaec4 100644 --- a/plc4go/internal/modbus/TagHandler.go +++ b/plc4go/internal/modbus/TagHandler.go @@ -30,6 +30,7 @@ import ( apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" readWriteModel "github.com/apache/plc4x/plc4go/protocols/modbus/readwrite/model" "github.com/apache/plc4x/plc4go/spi/errors" + spiModel "github.com/apache/plc4x/plc4go/spi/model" "github.com/apache/plc4x/plc4go/spi/options" "github.com/apache/plc4x/plc4go/spi/utils" ) @@ -68,12 +69,12 @@ type TagHandler struct { } func NewTagHandler(_options ...options.WithOption) TagHandler { - // STRING and WSTRING carry the length of one string in parentheses, the way plc4j's - // ModbusTag.ADDRESS_PATTERN spells it: "holding-register:1:STRING(20)[3]" is three - // 20-character strings. The quantity in brackets keeps meaning "how many values". Behind that, - // curly braces may carry per-tag settings (plc4j TagConfigParser.TAG_CONFIG_PATTERN). - generalAddressPattern := `(?P
\d+)(:(?P[a-zA-Z_]+)(\((?P\d+)\))?)?(\[(?P\d+)])?` + tagConfigPattern + `$` - generalFixedDigitAddressPattern := `(?P
\d{4,5})?(:(?P[a-zA-Z_]+)(\((?P\d+)\))?)?(\[(?P\d+)])?` + tagConfigPattern + `$` + // The selection sits between the address and the type, as it does in plc4j's + // ModbusTag.ADDRESS_PATTERN: "holding-register:1[0..2]:STRING(20)" is three 20-character + // strings. STRING and WSTRING carry the length of one string in parentheses. Behind all of + // that, curly braces may carry per-tag settings (plc4j TagConfigParser.TAG_CONFIG_PATTERN). + generalAddressPattern := `(?P
\d+)` + spiModel.ArrayGroupPattern + `(:(?P[a-zA-Z_]+)(\((?P\d+)\))?)?` + tagConfigPattern + `$` + generalFixedDigitAddressPattern := `(?P
\d{4,5})?` + spiModel.ArrayGroupPattern + `(:(?P[a-zA-Z_]+)(\((?P\d+)\))?)?` + tagConfigPattern + `$` customLogger := options.ExtractCustomLoggerOrDefaultToGlobal(_options...) return TagHandler{ plc4xCoilPattern: regexp.MustCompile("^coil:" + generalAddressPattern), @@ -143,9 +144,12 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { if err != nil { return nil, err } - return NewModbusPlcTagFromStrings(candidate.tagType, match["address"], match["quantity"], match["stringLength"], dataType, config, m.options...) + return NewModbusPlcTagFromStrings(candidate.tagType, match["address"], match["array"], match["stringLength"], dataType, config, m.options...) } - return nil, errors.Errorf("Invalid address format for address '%s'", tagAddress) + // "holding-register:1:INT[4]" - the count after the type - no longer parses, so name the + // form to write rather than reporting only that nothing matched. + return nil, spiModel.InvalidAddressError(tagAddress, + "{area}:{address}[selection]:{TYPE} - for example holding-register:1[0..3]:INT") } func (m TagHandler) ParseQuery(query string) (apiModel.PlcQuery, error) { diff --git a/plc4go/internal/modbus/TagHandler_test.go b/plc4go/internal/modbus/TagHandler_test.go index 4960a33d77f..05a2d796413 100644 --- a/plc4go/internal/modbus/TagHandler_test.go +++ b/plc4go/internal/modbus/TagHandler_test.go @@ -60,7 +60,7 @@ func TestTagHandler_ParseTag(t *testing.T) { // wire address itself (plc4j ModbusTagExtendedRegister.getLogicalAddress). {"plc4x extended register", "extended-register:7:DINT", ExtendedRegister, 7, 1, readWriteModel.ModbusDataType_DINT}, {"numeric extended register", "6x00007:DINT", ExtendedRegister, 7, 1, readWriteModel.ModbusDataType_DINT}, - {"with quantity", "holding-register:1:REAL[2]", HoldingRegister, 0, 2, readWriteModel.ModbusDataType_REAL}, + {"with quantity", "holding-register:1[0..1]:REAL", HoldingRegister, 0, 2, readWriteModel.ModbusDataType_REAL}, {"highest address", "holding-register:65535:INT", HoldingRegister, 65534, 1, readWriteModel.ModbusDataType_INT}, } for _, test := range tests { @@ -97,8 +97,8 @@ func TestTagHandler_ParseTag_defaultsTheDatatype(t *testing.T) { assert.Equal(t, test.datatype, parseTag(t, test.address).Datatype) }) } - t.Run("quantity without datatype", func(t *testing.T) { - tag := parseTag(t, "holding-register:1[4]") + t.Run("a selection without a datatype", func(t *testing.T) { + tag := parseTag(t, "holding-register:1[0..3]") assert.Equal(t, readWriteModel.ModbusDataType_INT, tag.Datatype) assert.Equal(t, uint16(4), tag.Quantity) }) @@ -116,16 +116,18 @@ func TestTagHandler_ParseTag_rejectsInvalidAddresses(t *testing.T) { {"numeric logical address zero", "4x00000:INT"}, {"address beyond the address space", "holding-register:65537:INT"}, {"extended register address zero", "extended-register:0:INT"}, - {"quantity zero", "holding-register:1:INT[0]"}, + // There is no way to ask for zero elements any more - a range is written with the + // indices it covers - but an inverted one is still nonsense. + {"inverted range", "holding-register:1[3..1]:INT"}, // plc4j rejects a range whose last address reaches the end of the address space // (ModbusTagHoldingRegister.of checks address + quantity > REGISTER_MAXADDRESS). {"range reaching the end of the address space", "holding-register:65536:INT"}, - {"range running past the address space", "holding-register:65535:INT[2]"}, - {"too many coils", "coil:1:BOOL[2001]"}, - {"too many discrete inputs", "discrete-input:1:BOOL[2001]"}, - {"too many holding registers", "holding-register:1:INT[126]"}, - {"too many input registers", "input-register:1:INT[126]"}, - {"too many extended registers", "extended-register:1:INT[126]"}, + {"range running past the address space", "holding-register:65535[0..1]:INT"}, + {"too many coils", "coil:1[0..2000]:BOOL"}, + {"too many discrete inputs", "discrete-input:1[0..2000]:BOOL"}, + {"too many holding registers", "holding-register:1[0..125]:INT"}, + {"too many input registers", "input-register:1[0..125]:INT"}, + {"too many extended registers", "extended-register:1[0..125]:INT"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -141,38 +143,43 @@ func TestTagHandler_ParseTag_rejectsInvalidAddresses(t *testing.T) { // other area is shifted by one. func TestModbusTag_extendedRegisterAddressesAreNotShifted(t *testing.T) { assert.Equal(t, uint16(7), parseTag(t, "extended-register:7:DINT").Address) - assert.Equal(t, "6x00007:DINT[1]", parseTag(t, "extended-register:7:DINT").GetAddressString()) + assert.Equal(t, "6x00007:DINT", parseTag(t, "extended-register:7:DINT").GetAddressString()) assert.Equal(t, uint16(6), parseTag(t, "holding-register:7:DINT").Address) - assert.Equal(t, "4x00007:DINT[1]", parseTag(t, "holding-register:7:DINT").GetAddressString()) + assert.Equal(t, "4x00007:DINT", parseTag(t, "holding-register:7:DINT").GetAddressString()) } -// A quantity that was spelled out but doesn't fit into the field must be an error. Silently -// falling back to a single element would read or write the wrong amount of data. -func TestNewModbusPlcTagFromStrings_rejectsAnUnparsableQuantity(t *testing.T) { - tag, err := NewModbusPlcTagFromStrings(HoldingRegister, "1", "99999999999999999999", "", readWriteModel.ModbusDataType_INT, tagConfig{}) - assert.Error(t, err) - assert.Nil(t, tag) +// A selection that was spelled out but doesn't parse must be an error. Silently falling back to +// a single element would read or write the wrong amount of data. +func TestNewModbusPlcTagFromStrings_rejectsAnUnparsableSelection(t *testing.T) { + for _, expression := range []string{"[99999999999999999999]", "[0..]", "[2..1]", "[1,2][3]"} { + tag, err := NewModbusPlcTagFromStrings(HoldingRegister, "1", expression, "", readWriteModel.ModbusDataType_INT, tagConfig{}) + assert.Error(t, err, expression) + assert.Nil(t, tag, expression) + } } +// An address with no selection reads one element where it says. func TestNewModbusPlcTagFromStrings_defaultsQuantityToOne(t *testing.T) { tag, err := NewModbusPlcTagFromStrings(HoldingRegister, "1", "", "", readWriteModel.ModbusDataType_INT, tagConfig{}) require.NoError(t, err) assert.Equal(t, uint16(1), tag.(modbusTag).Quantity) } -// The Go SPI treats the upper bound of an ArrayInfo as exclusive - DefaultArrayInfo.GetSize is -// UpperBound-LowerBound and every consumer subtracts the two to get the element count. Modbus -// follows that convention, which is why it differs from plc4j's inclusive quantity-1. -func TestModbusTag_GetArrayInfoUsesAnExclusiveUpperBound(t *testing.T) { - arrayInfo := parseTag(t, "holding-register:1:INT[5]").GetArrayInfo() +// Both bounds of an ArrayInfo are inclusive, so five elements are {0, 4} and GetSize is +// UpperBound-LowerBound+1. This used to be exclusive in plc4go and deliberately differed from +// plc4j; it was changed because once ranges are written by the user the bounds are the indices +// the address stated, and [0..4] has an upper bound of 4 - an exclusive bound would report 5, +// a number appearing nowhere in the address. +func TestModbusTag_GetArrayInfoUsesAnInclusiveUpperBound(t *testing.T) { + arrayInfo := parseTag(t, "holding-register:1[0..4]:INT").GetArrayInfo() require.Len(t, arrayInfo, 1) assert.Equal(t, uint32(0), arrayInfo[0].GetLowerBound()) - assert.Equal(t, uint32(5), arrayInfo[0].GetUpperBound()) + assert.Equal(t, uint32(4), arrayInfo[0].GetUpperBound(), "the last index, not the count") assert.Equal(t, uint32(5), arrayInfo[0].GetSize(), "the size must be the number of elements") // Same shape as what the other Go drivers produce for five elements. - var reference apiModel.ArrayInfo = &spiModel.DefaultArrayInfo{LowerBound: 0, UpperBound: 5} + var reference apiModel.ArrayInfo = &spiModel.DefaultArrayInfo{LowerBound: 0, UpperBound: 4} assert.Equal(t, reference.GetSize(), arrayInfo[0].GetSize()) // A single element isn't an array at all. @@ -191,7 +198,7 @@ func TestTagHandler_ParseTag_stringLength(t *testing.T) { {"holding-register:1:STRING(20)", readWriteModel.ModbusDataType_STRING, 20, 1}, {"holding-register:1:WSTRING(20)", readWriteModel.ModbusDataType_WSTRING, 20, 1}, {"4x00001:STRING(8)", readWriteModel.ModbusDataType_STRING, 8, 1}, - {"holding-register:1:STRING(20)[3]", readWriteModel.ModbusDataType_STRING, 20, 3}, + {"holding-register:1[0..2]:STRING(20)", readWriteModel.ModbusDataType_STRING, 20, 3}, {"input-register:7:STRING(1)", readWriteModel.ModbusDataType_STRING, 1, 1}, } { t.Run(test.address, func(t *testing.T) { @@ -208,7 +215,7 @@ func TestTagHandler_ParseTag_stringLength(t *testing.T) { func TestTagHandler_ParseTag_nonStringsHaveAStringLengthOfOne(t *testing.T) { assert.Equal(t, uint16(1), parseTag(t, "holding-register:1:INT").StringLength) assert.Equal(t, uint16(1), parseTag(t, "coil:1").StringLength) - assert.Equal(t, uint16(1), parseTag(t, "holding-register:1:CHAR[4]").StringLength) + assert.Equal(t, uint16(1), parseTag(t, "holding-register:1[0..3]:CHAR").StringLength) } func TestTagHandler_ParseTag_rejectsABadStringLength(t *testing.T) { @@ -253,7 +260,7 @@ func TestTagHandler_ParseTag_tagConfig(t *testing.T) { assert.Equal(t, BigEndianByteSwapOrder, *tag.ByteOrder) }) t.Run("together with a quantity and a string length", func(t *testing.T) { - tag := parseTag(t, "holding-register:1:STRING(4)[2]{unit-id: 9}") + tag := parseTag(t, "holding-register:1[0..1]:STRING(4){unit-id: 9}") assert.Equal(t, uint16(4), tag.StringLength) assert.Equal(t, uint16(2), tag.Quantity) require.NotNil(t, tag.UnitId) @@ -317,12 +324,12 @@ func TestModbusTag_resolvesUnitIdAndByteOrderAgainstTheConnectionDefaults(t *tes func TestModbusTag_GetAddressStringRoundTrips(t *testing.T) { for _, address := range []string{ "holding-register:1:INT", - "holding-register:1:STRING(20)[3]", + "holding-register:1[0..2]:STRING(20)", "holding-register:1:REAL{unit-id: 7}", "holding-register:1:DINT{byte-order: 'LITTLE_ENDIAN_BYTE_SWAP'}", - "holding-register:1:WSTRING(4)[2]{unit-id: 9, byte-order: 'BIG_ENDIAN_BYTE_SWAP'}", + "holding-register:1[0..1]:WSTRING(4){unit-id: 9, byte-order: 'BIG_ENDIAN_BYTE_SWAP'}", "extended-register:7:DINT", - "extended-register:12345:INT[2]", + "extended-register:12345[0..1]:INT", } { t.Run(address, func(t *testing.T) { tag := parseTag(t, address) @@ -341,16 +348,16 @@ func TestModbusTag_lengthWordsCountsTheStringLength(t *testing.T) { {"holding-register:1:INT", 1}, {"holding-register:1:BOOL", 1}, {"holding-register:1:REAL", 2}, - {"holding-register:1:INT[4]", 4}, + {"holding-register:1[0..3]:INT", 4}, {"holding-register:1:STRING(20)", 10}, - {"holding-register:1:STRING(20)[3]", 30}, + {"holding-register:1[0..2]:STRING(20)", 30}, {"holding-register:1:WSTRING(20)", 20}, {"holding-register:1:STRING(3)", 2}, // Several values narrower than a byte are packed, so three BOOLs share one register // instead of taking one each. - {"holding-register:1:BOOL[3]", 1}, - {"holding-register:1:BOOL[17]", 2}, - {"holding-register:1:SINT[3]", 2}, + {"holding-register:1[0..2]:BOOL", 1}, + {"holding-register:1[0..16]:BOOL", 2}, + {"holding-register:1[0..2]:SINT", 2}, } { t.Run(test.address, func(t *testing.T) { words, err := parseTag(t, test.address).lengthWords() @@ -361,8 +368,101 @@ func TestModbusTag_lengthWordsCountsTheStringLength(t *testing.T) { } // A payload that doesn't fit into the 16 bit quantity field of a request is an error, not a -// truncated request that would silently read the wrong amount of data. +// truncated request that would silently read the wrong amount of data. The register count the +// selection resolves to is now checked while the address is parsed, so this is reported there - +// 125 strings of 65535 bytes are 4095938 registers, far past the end of the address space. func TestModbusTag_lengthWordsRejectsAnOversizedPayload(t *testing.T) { - _, err := parseTag(t, "holding-register:1:STRING(65535)[125]").lengthWords() + _, err := NewTagHandler().ParseTag("holding-register:1[0..124]:STRING(65535)") + assert.Error(t, err) +} + +// The per-request ceiling and the address space are counted in registers, not in elements. 63 +// DINTs are 126 registers and do not fit into the 125 a read carries, however few elements that +// is; counting elements let this through and truncated the request on the wire. +func TestTagHandler_ParseTag_limitsAreCountedInRegisters(t *testing.T) { + _, err := NewTagHandler().ParseTag("holding-register:1[0..62]:DINT") + assert.Error(t, err) + + // 62 DINTs are 124 registers and still fit. + _, err = NewTagHandler().ParseTag("holding-register:1[0..61]:DINT") + assert.NoError(t, err) + + // A bit area addresses bits, where one element is one address, so its own ceiling still + // applies to the element count. + _, err = NewTagHandler().ParseTag("coil:1[0..1999]") + assert.NoError(t, err) +} + +// A selection whose start does not land on a register boundary cannot be addressed by a read at +// all. The register codec packs a CHAR into 8 bits, so an odd element offset falls inside a +// register; rounding each element up to a whole one placed the start where nothing was written. +func TestTagHandler_ParseTag_rejectsASelectionStartingInsideARegister(t *testing.T) { + _, err := NewTagHandler().ParseTag("holding-register:1[1..2]:CHAR") assert.Error(t, err) + + // An even offset lands on a boundary and stays legal. + _, err = NewTagHandler().ParseTag("holding-register:1[2..3]:CHAR") + assert.NoError(t, err) +} + +// A Modbus address is a register number, so a selection that starts past the declared base is +// resolved into the address itself: "holding-register:1[4..7]" is the same read as +// "holding-register:5[0..3]", four registers further along. What the caller sees afterwards is +// the resolved address, which is why the rendered form carries [0..3] either way. +func TestTagHandler_ParseTag_consumesTheSelectionOffset(t *testing.T) { + shifted := parseTag(t, "holding-register:1[4..7]:INT") + assert.Equal(t, parseTag(t, "holding-register:5[0..3]:INT"), shifted) + assert.Equal(t, "4x00005[0..3]:INT", shifted.GetAddressString()) + + // A declared base is what the offset is measured from, so [4..7;4] shifts nothing. + assert.Equal(t, parseTag(t, "holding-register:1[0..3]:INT"), parseTag(t, "holding-register:1[4..7;4]:INT")) + + // A bare index selects one element, at that index - not that many elements. + single := parseTag(t, "holding-register:1[4]:INT") + assert.Equal(t, uint16(1), single.Quantity) + assert.Equal(t, uint16(4), single.Address, "register 5 on the wire, which is 4 zero-based") + assert.Empty(t, single.GetArrayInfo(), "one element is a scalar") +} + +// Addresses written with the count after the type must fail, naming what to write instead. +func TestTagHandler_ParseTag_rejectsTheOldCountSuffix(t *testing.T) { + handler := NewTagHandler() + for _, address := range []string{"holding-register:1:INT[4]", "4x00001:INT[4]", "coil:1:BOOL[8]", "holding-register:1:STRING(20)[3]"} { + t.Run(address, func(t *testing.T) { + _, err := handler.ParseTag(address) + require.Error(t, err, address) + assert.Contains(t, err.Error(), "invalid address", address) + }) + } +} + +// A Modbus read covers one contiguous run of registers, so a second dimension has nothing to +// map onto. +func TestTagHandler_ParseTag_rejectsASecondDimension(t *testing.T) { + _, err := NewTagHandler().ParseTag("holding-register:1[0..1][2..3]:INT") + assert.Error(t, err) +} + +// A selection offset counts elements; a Modbus address counts registers. They are the same number +// only for a one-register type, which is why every example using INT looked right while +// "holding-register:1[4]:DINT" silently addressed four registers short of its target. +// +// The read length already scales (lengthWords), so the unscaled offset did not shorten the read - +// it moved it. +func TestTagHandler_ParseTag_scalesTheOffsetByTheElementWidth(t *testing.T) { + // 40001 is wire address 0; the fifth INT is four registers along. + assert.Equal(t, uint16(4), parseTag(t, "holding-register:1[4]:INT").Address) + // The fifth DINT begins eight registers along. + assert.Equal(t, uint16(8), parseTag(t, "holding-register:1[4]:DINT").Address) + // And a LINT is four registers wide. + assert.Equal(t, uint16(8), parseTag(t, "holding-register:1[2]:LINT").Address) + // A STRING(20) occupies ten registers, so the third begins twenty along. + assert.Equal(t, uint16(20), parseTag(t, "holding-register:1[2]:STRING(20)").Address) + + // The other register areas follow the same rule. + assert.Equal(t, uint16(8), parseTag(t, "input-register:1[4]:DINT").Address) + + // A bit area addresses bits: one coil is one address, so there is nothing to scale by. + assert.Equal(t, uint16(4), parseTag(t, "coil:1[4]:BOOL").Address) + assert.Equal(t, uint16(4), parseTag(t, "discrete-input:1[4]:BOOL").Address) } diff --git a/plc4go/internal/modbus/Writer_test.go b/plc4go/internal/modbus/Writer_test.go index 6cebce5a868..337d305629b 100644 --- a/plc4go/internal/modbus/Writer_test.go +++ b/plc4go/internal/modbus/Writer_test.go @@ -393,7 +393,7 @@ func TestWriter_registerWriteRejectsAPayloadThatIsntWholeRegisters(t *testing.T) // An odd number of strings fills its registers exactly, so it goes out with a quantity that // matches the byte count. func TestWriter_writesAnOddNumberOfStrings(t *testing.T) { - tag := parseTag(t, "holding-register:1:STRING(4)[3]") + tag := parseTag(t, "holding-register:1[0..2]:STRING(4)") value := spiValues.NewPlcList([]apiValues.PlcValue{ spiValues.NewPlcSTRING("ab"), spiValues.NewPlcSTRING("cd"), spiValues.NewPlcSTRING("ef"), }) @@ -409,7 +409,7 @@ func TestWriter_writesAnOddNumberOfStrings(t *testing.T) { // Several BOOLs share a register, so the quantity a write announces is the number of registers the // packed bits occupy rather than one per value. func TestWriter_writesPackedBoolsAsOneRegister(t *testing.T) { - tag := parseTag(t, "holding-register:1:BOOL[3]") + tag := parseTag(t, "holding-register:1[0..2]:BOOL") value := spiValues.NewPlcList([]apiValues.PlcValue{ spiValues.NewPlcBOOL(true), spiValues.NewPlcBOOL(false), spiValues.NewPlcBOOL(true), }) diff --git a/plc4go/internal/s7/Tag.go b/plc4go/internal/s7/Tag.go index cdc50445769..c0441877d3d 100644 --- a/plc4go/internal/s7/Tag.go +++ b/plc4go/internal/s7/Tag.go @@ -51,18 +51,28 @@ type plcTag struct { ByteOffset uint16 BitOffset uint8 NumElements uint16 - Datatype readWriteModel.TransportSize + // ExplicitRange records whether the address wrote the selection as a range. A one-element + // range is still a range - [4] is a scalar and [4..4] a list of one - which no count can say. + ExplicitRange bool + Datatype readWriteModel.TransportSize } func NewTag(memoryArea readWriteModel.MemoryArea, blockNumber uint16, byteOffset uint16, bitOffset uint8, numElements uint16, datatype readWriteModel.TransportSize) PlcTag { + return NewTagWithShape(memoryArea, blockNumber, byteOffset, bitOffset, numElements, datatype, numElements > 1) +} + +// NewTagWithShape is NewTag plus what the address said about its shape: a range is an array even +// when it spans one element, which the element count alone cannot carry. +func NewTagWithShape(memoryArea readWriteModel.MemoryArea, blockNumber uint16, byteOffset uint16, bitOffset uint8, numElements uint16, datatype readWriteModel.TransportSize, explicitRange bool) PlcTag { return plcTag{ - TagType: S7Tag, - MemoryArea: memoryArea, - BlockNumber: blockNumber, - ByteOffset: byteOffset, - BitOffset: bitOffset, - NumElements: numElements, - Datatype: datatype, + ExplicitRange: explicitRange, + TagType: S7Tag, + MemoryArea: memoryArea, + BlockNumber: blockNumber, + ByteOffset: byteOffset, + BitOffset: bitOffset, + NumElements: numElements, + Datatype: datatype, } } @@ -72,21 +82,40 @@ type PlcStringTag struct { } func NewStringTag(memoryArea readWriteModel.MemoryArea, blockNumber uint16, byteOffset uint16, bitOffset uint8, numElements uint16, stringLength uint16, datatype readWriteModel.TransportSize) PlcStringTag { + return NewStringTagWithShape(memoryArea, blockNumber, byteOffset, bitOffset, numElements, stringLength, datatype, numElements > 1) +} + +// NewStringTagWithShape is NewStringTag plus what the address said about its shape: a range is an array even +// when it spans one element, which the element count alone cannot carry. +func NewStringTagWithShape(memoryArea readWriteModel.MemoryArea, blockNumber uint16, byteOffset uint16, bitOffset uint8, numElements uint16, stringLength uint16, datatype readWriteModel.TransportSize, explicitRange bool) PlcStringTag { return PlcStringTag{ - TagType: S7StringTag, - MemoryArea: memoryArea, - BlockNumber: blockNumber, - ByteOffset: byteOffset, - BitOffset: bitOffset, - NumElements: numElements, - Datatype: datatype, - stringLength: stringLength, + TagType: S7StringTag, + MemoryArea: memoryArea, + BlockNumber: blockNumber, + ByteOffset: byteOffset, + BitOffset: bitOffset, + NumElements: numElements, + ExplicitRange: explicitRange, + Datatype: datatype, + stringLength: stringLength, } } +// GetAddressString spells the tag the way the tag handler parses it back. It used to render the +// tag type as a number and nothing else of the address - "0:INT[8]" - which named neither the +// memory area nor the offset it read, and did not parse back into anything. func (m plcTag) GetAddressString() string { - // TODO: add missing variables like memory area, block number, byte offset, bit offset - return fmt.Sprintf("%d:%s[%d]", m.TagType, m.Datatype, m.NumElements) + var address string + if m.MemoryArea == readWriteModel.MemoryArea_DATA_BLOCKS { + address = fmt.Sprintf("%%DB%d.DB%d", m.BlockNumber, m.ByteOffset) + } else { + address = fmt.Sprintf("%%%s%d", m.MemoryArea.ShortName(), m.ByteOffset) + } + // A bit offset is only part of an address for BOOL, and is required there. + if m.Datatype == readWriteModel.TransportSize_BOOL { + address += fmt.Sprintf(".%d", m.BitOffset) + } + return address + spiModel.RenderArrayExpression(m.GetArrayInfo()) + ":" + m.Datatype.String() } func (m plcTag) GetValueType() apiValues.PlcValueType { @@ -96,12 +125,17 @@ func (m plcTag) GetValueType() apiValues.PlcValueType { return apiValues.NULL } +// GetArrayInfo reports the shape of the value the caller receives, not the indices the address +// was written with: an S7 address names a byte offset, so the driver consumes the start of the +// selection when it resolves the address and what remains is a count of elements from there. func (m plcTag) GetArrayInfo() []apiModel.ArrayInfo { - if m.NumElements != 1 { + // The flag decides the shape; the count only sizes it. + if m.ExplicitRange { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(m.NumElements), + UpperBound: uint32(m.NumElements) - 1, + Range: true, }, } } @@ -193,6 +227,20 @@ func (m plcTag) String() string { return wb.GetBox().String() } +// GetAddressString spells a string tag the way the tag handler parses it back, which means +// carrying the declared length: without it the address reads as a variable-length string. A +// variable-length tag renders its assumed length, which is the length it was already given. +func (m PlcStringTag) GetAddressString() string { + var address string + if m.MemoryArea == readWriteModel.MemoryArea_DATA_BLOCKS { + address = fmt.Sprintf("%%DB%d.DB%d", m.BlockNumber, m.ByteOffset) + } else { + address = fmt.Sprintf("%%%s%d", m.MemoryArea.ShortName(), m.ByteOffset) + } + return fmt.Sprintf("%s%s:%s(%d)", address, + spiModel.RenderArrayExpression(m.GetArrayInfo()), m.Datatype, m.stringLength) +} + func (m PlcStringTag) Serialize() ([]byte, error) { wb := utils.NewWriteBufferByteBased(utils.WithByteOrderForByteBasedBuffer(binary.BigEndian)) if err := m.SerializeWithWriteBuffer(context.Background(), wb); err != nil { diff --git a/plc4go/internal/s7/TagHandler.go b/plc4go/internal/s7/TagHandler.go index e2fca04d5b3..4956fbb1eb1 100644 --- a/plc4go/internal/s7/TagHandler.go +++ b/plc4go/internal/s7/TagHandler.go @@ -31,6 +31,7 @@ import ( apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" readWriteModel "github.com/apache/plc4x/plc4go/protocols/s7/readwrite/model" "github.com/apache/plc4x/plc4go/spi/errors" + spiModel "github.com/apache/plc4x/plc4go/spi/model" "github.com/apache/plc4x/plc4go/spi/options" "github.com/apache/plc4x/plc4go/spi/utils" ) @@ -62,19 +63,23 @@ type TagHandler struct { log zerolog.Logger } +// Every address pattern is anchored at both ends. plc4j matches these with matcher.matches(), +// which is full-string; Go's FindStringSubmatch is not, so without the anchor an address the +// migration invalidated - "%DB69.DBX68:WSTRING[3]", with the count after the type - matched a +// different pattern up to the type and was accepted as a different kind of tag entirely. func NewTagHandler(_options ...options.WithOption) TagHandler { passLoggerToModel, _ := options.ExtractPassLoggerToModel(_options...) customLogger := options.ExtractCustomLoggerOrDefaultToGlobal(_options...) return TagHandler{ // the (?:S5)? prefix is required because the character class doesn't allow digits (S5TIME) - addressPattern: regexp.MustCompile(`^%(?P.)(?P[XBWD]?)(?P\d{1,7})(.(?P[0-7]))?:(?P(?:S5)?[a-zA-Z_]+)(\[(?P\d+)])?`), + addressPattern: regexp.MustCompile(`^%(?P.)(?P[XBWD]?)(?P\d{1,7})(.(?P[0-7]))?` + spiModel.ArrayGroupPattern + `:(?P(?:S5)?[a-zA-Z_]+)$`), //blockNumber usually has its max hat around 64000 --> 5digits - dataBlockAddressPattern: regexp.MustCompile(`^%DB(?P\d{1,5}).DB(?P[XBWD]?)(?P\d{1,7})(.(?P[0-7]))?:(?P(?:S5)?[a-zA-Z_]+)(\[(?P\d+)])?`), - dataBlockShortPattern: regexp.MustCompile(`^%DB(?P\d{1,5}):(?P\d{1,7})(.(?P[0-7]))?:(?P(?:S5)?[a-zA-Z_]+)(\[(?P\d+)])?`), - dataBlockStringAddressPattern: regexp.MustCompile(`^%DB(?P\d{1,5}).DB(?P[XBWD]?)(?P\d{1,7})(.(?P[0-7]))?:(?PSTRING|WSTRING)\((?P\d{1,3})\)(\[(?P\d+)])?`), - dataBlockStringShortPattern: regexp.MustCompile(`^%DB(?P\d{1,5}):(?P\d{1,7})(.(?P[0-7]))?:(?PSTRING|WSTRING)\((?P\d{1,3})\)(\[(?P\d+)])?`), - dataBlockStringVarLengthAddressPattern: regexp.MustCompile(`^%DB(?P\d{1,5}).DB(?P[XBWD]?)(?P\d{1,7})(.(?P[0-7]))?:(?PSTRING|WSTRING)(\[(?P\d+)])?$`), - dataBlockStringVarLengthShortPattern: regexp.MustCompile(`^%DB(?P\d{1,5}):(?P\d{1,7})(.(?P[0-7]))?:(?PSTRING|WSTRING)(\[(?P\d+)])?$`), + dataBlockAddressPattern: regexp.MustCompile(`^%DB(?P\d{1,5}).DB(?P[XBWD]?)(?P\d{1,7})(.(?P[0-7]))?` + spiModel.ArrayGroupPattern + `:(?P(?:S5)?[a-zA-Z_]+)$`), + dataBlockShortPattern: regexp.MustCompile(`^%DB(?P\d{1,5}):(?P\d{1,7})(.(?P[0-7]))?` + spiModel.ArrayGroupPattern + `:(?P(?:S5)?[a-zA-Z_]+)$`), + dataBlockStringAddressPattern: regexp.MustCompile(`^%DB(?P\d{1,5}).DB(?P[XBWD]?)(?P\d{1,7})(.(?P[0-7]))?` + spiModel.ArrayGroupPattern + `:(?PSTRING|WSTRING)\((?P\d{1,3})\)$`), + dataBlockStringShortPattern: regexp.MustCompile(`^%DB(?P\d{1,5}):(?P\d{1,7})(.(?P[0-7]))?` + spiModel.ArrayGroupPattern + `:(?PSTRING|WSTRING)\((?P\d{1,3})\)$`), + dataBlockStringVarLengthAddressPattern: regexp.MustCompile(`^%DB(?P\d{1,5}).DB(?P[XBWD]?)(?P\d{1,7})(.(?P[0-7]))?` + spiModel.ArrayGroupPattern + `:(?PSTRING|WSTRING)$`), + dataBlockStringVarLengthShortPattern: regexp.MustCompile(`^%DB(?P\d{1,5}):(?P\d{1,7})(.(?P[0-7]))?` + spiModel.ArrayGroupPattern + `:(?PSTRING|WSTRING)$`), plcProxyAddressPattern: regexp.MustCompile(`[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}`), passLogToModel: passLoggerToModel, @@ -82,6 +87,9 @@ func NewTagHandler(_options ...options.WithOption) TagHandler { } } +// maxByteOffset is the largest byte address S7AddressAny can carry - the field is 16 bits. +const maxByteOffset = 0xFFFF + const ( DATA_TYPE = "dataType" STRING_LENGTH = "stringLength" @@ -89,7 +97,7 @@ const ( BLOCK_NUMBER = "blockNumber" BYTE_OFFSET = "byteOffset" BIT_OFFSET = "bitOffset" - NUM_ELEMENTS = "numElements" + ARRAY = "array" MEMORY_AREA = "memoryArea" ) @@ -109,6 +117,17 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { } stringLength := uint16(parsedStringLength) memoryArea := readWriteModel.MemoryArea_DATA_BLOCKS + // The data block the address names. This branch used to hand a hard-coded 0 to the tag, + // so "%DB69.DBX68:STRING(10)" read DB0 rather than DB69 - a different block, reported + // as though it were the one asked for. Every other branch already parses this. + parsedBlockNumber, err := strconv.ParseUint(match[BLOCK_NUMBER], 10, 16) + if err != nil { + return nil, errors.Wrap(err, "Error converting blocknumber") + } + blockNumber, err := checkDatablockNumber(parsedBlockNumber) + if err != nil { + return nil, errors.Wrap(err, "Error checking blocknumber") + } transferSizeCode := getSizeCode(match[TRANSFER_SIZE_CODE]) parsedByteOffset, err := strconv.ParseUint(match[BYTE_OFFSET], 10, 16) if err != nil { @@ -128,20 +147,20 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { } else if dataType == readWriteModel.TransportSize_BOOL { return nil, errors.New("Expected bit offset for BOOL parameters.") } - numElements := uint16(1) - if match[NUM_ELEMENTS] != "" { - parsedNumElements, err := strconv.ParseUint(match[NUM_ELEMENTS], 10, 16) - if err != nil { - return nil, errors.Wrap(err, "Error converting numelements") - } - numElements = uint16(parsedNumElements) + offset, numElements, explicitRange, err := selectionOf(match, tagAddress, bytesPerString(dataType, stringLength)) + if err != nil { + return nil, err + } + byteOffset, err = checkByteOffset(uint64(byteOffset) + uint64(offset)) + if err != nil { + return nil, errors.Wrap(err, "Error applying the array selection") } if (transferSizeCode != 0) && (dataType.ShortName() != transferSizeCode) { return nil, errors.Errorf("Transfer size code '%d' doesn't match specified data type '%s'", transferSizeCode, dataType) } - return NewStringTag(memoryArea, 0, byteOffset, bitOffset, numElements, stringLength, dataType), nil + return NewStringTagWithShape(memoryArea, blockNumber, byteOffset, bitOffset, numElements, stringLength, dataType, explicitRange), nil } else if match := utils.GetSubgroupMatches(m.dataBlockStringShortPattern, tagAddress); match != nil { dataType, ok := readWriteModel.TransportSizeByName(match[DATA_TYPE]) if !ok { @@ -170,16 +189,16 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { return nil, errors.Wrap(err, "Error converting byteoffset") } bitOffset := uint8(0) - numElements := uint16(1) - if match[NUM_ELEMENTS] != "" { - parsedNumElements, err := strconv.ParseUint(match[NUM_ELEMENTS], 10, 16) - if err != nil { - return nil, errors.Wrap(err, "Error converting numelements") - } - numElements = uint16(parsedNumElements) + offset, numElements, explicitRange, err := selectionOf(match, tagAddress, bytesPerString(dataType, stringLength)) + if err != nil { + return nil, err + } + byteOffset, err = checkByteOffset(uint64(byteOffset) + uint64(offset)) + if err != nil { + return nil, errors.Wrap(err, "Error applying the array selection") } - return NewStringTag(memoryArea, blockNumber, byteOffset, bitOffset, numElements, stringLength, dataType), nil + return NewStringTagWithShape(memoryArea, blockNumber, byteOffset, bitOffset, numElements, stringLength, dataType, explicitRange), nil } else if match := utils.GetSubgroupMatches(m.dataBlockStringVarLengthAddressPattern, tagAddress); match != nil { dataType, ok := readWriteModel.TransportSizeByName(match[DATA_TYPE]) if !ok { @@ -211,13 +230,13 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { } bitOffset = uint8(parsedBitOffset) } - numElements := uint16(1) - if match[NUM_ELEMENTS] != "" { - parsedNumElements, err := strconv.ParseUint(match[NUM_ELEMENTS], 10, 16) - if err != nil { - return nil, errors.Wrap(err, "Error converting numelements") - } - numElements = uint16(parsedNumElements) + offset, numElements, explicitRange, err := selectionOf(match, tagAddress, assumedBytesPerVarLengthString(dataType)) + if err != nil { + return nil, err + } + byteOffset, err = checkByteOffset(uint64(byteOffset) + uint64(offset)) + if err != nil { + return nil, errors.Wrap(err, "Error applying the array selection") } if (transferSizeCode != 0) && (dataType.ShortName() != transferSizeCode) { @@ -225,7 +244,7 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { } // Var-length strings behave like fixed-length strings with the maximum length of 254. - return NewStringTag(memoryArea, blockNumber, byteOffset, bitOffset, numElements, 254, dataType), nil + return NewStringTagWithShape(memoryArea, blockNumber, byteOffset, bitOffset, numElements, 254, dataType, explicitRange), nil } else if match := utils.GetSubgroupMatches(m.dataBlockStringVarLengthShortPattern, tagAddress); match != nil { dataType, ok := readWriteModel.TransportSizeByName(match[DATA_TYPE]) if !ok { @@ -249,17 +268,17 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { return nil, errors.Wrap(err, "Error converting byteoffset") } bitOffset := uint8(0) - numElements := uint16(1) - if match[NUM_ELEMENTS] != "" { - parsedNumElements, err := strconv.ParseUint(match[NUM_ELEMENTS], 10, 16) - if err != nil { - return nil, errors.Wrap(err, "Error converting numelements") - } - numElements = uint16(parsedNumElements) + offset, numElements, explicitRange, err := selectionOf(match, tagAddress, assumedBytesPerVarLengthString(dataType)) + if err != nil { + return nil, err + } + byteOffset, err = checkByteOffset(uint64(byteOffset) + uint64(offset)) + if err != nil { + return nil, errors.Wrap(err, "Error applying the array selection") } // Var-length strings behave like fixed-length strings with the maximum length of 254. - return NewStringTag(memoryArea, blockNumber, byteOffset, bitOffset, numElements, 254, dataType), nil + return NewStringTagWithShape(memoryArea, blockNumber, byteOffset, bitOffset, numElements, 254, dataType, explicitRange), nil } else if match := utils.GetSubgroupMatches(m.dataBlockAddressPattern, tagAddress); match != nil { dataType, ok := readWriteModel.TransportSizeByName(match[DATA_TYPE]) if !ok { @@ -293,20 +312,20 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { } else if dataType == readWriteModel.TransportSize_BOOL { return nil, errors.New("Expected bit offset for BOOL parameters.") } - numElements := uint16(1) - if match[NUM_ELEMENTS] != "" { - parsedNumElements, err := strconv.ParseUint(match[NUM_ELEMENTS], 10, 16) - if err != nil { - return nil, errors.Wrap(err, "Error converting numelements") - } - numElements = uint16(parsedNumElements) + offset, numElements, explicitRange, err := selectionOf(match, tagAddress, uint32(dataType.SizeInBytes())) + if err != nil { + return nil, err + } + byteOffset, err = checkByteOffset(uint64(byteOffset) + uint64(offset)) + if err != nil { + return nil, errors.Wrap(err, "Error applying the array selection") } if (transferSizeCode != 0) && (dataType.ShortName() != transferSizeCode) { return nil, errors.Errorf("Transfer size code '%d' doesn't match specified data type '%s'", transferSizeCode, dataType) } - return NewTag(memoryArea, blockNumber, byteOffset, bitOffset, numElements, dataType), nil + return NewTagWithShape(memoryArea, blockNumber, byteOffset, bitOffset, numElements, dataType, explicitRange), nil } else if match := utils.GetSubgroupMatches(m.dataBlockShortPattern, tagAddress); match != nil { dataType, ok := readWriteModel.TransportSizeByName(match[DATA_TYPE]) if !ok { @@ -339,16 +358,16 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { } else if dataType == readWriteModel.TransportSize_BOOL { return nil, errors.New("Expected bit offset for BOOL parameters.") } - numElements := uint16(1) - if match[NUM_ELEMENTS] != "" { - parsedNumElements, err := strconv.ParseUint(match[NUM_ELEMENTS], 10, 16) - if err != nil { - return nil, errors.Wrap(err, "Error converting numelements") - } - numElements = uint16(parsedNumElements) + offset, numElements, explicitRange, err := selectionOf(match, tagAddress, uint32(dataType.SizeInBytes())) + if err != nil { + return nil, err + } + byteOffset, err = checkByteOffset(uint64(byteOffset) + uint64(offset)) + if err != nil { + return nil, errors.Wrap(err, "Error applying the array selection") } - return NewTag(memoryArea, blockNumber, byteOffset, bitOffset, numElements, dataType), nil + return NewTagWithShape(memoryArea, blockNumber, byteOffset, bitOffset, numElements, dataType, explicitRange), nil } else if match := utils.GetSubgroupMatches(m.plcProxyAddressPattern, tagAddress); match != nil { addressData, err := hex.DecodeString(strings.ReplaceAll(tagAddress, "[-]", "")) if err != nil { @@ -400,13 +419,13 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { } else if dataType == readWriteModel.TransportSize_BOOL { return nil, errors.New("Expected bit offset for BOOL parameters.") } - numElements := uint16(1) - if match[NUM_ELEMENTS] != "" { - parsedNumElements, err := strconv.ParseUint(match[NUM_ELEMENTS], 10, 16) - if err != nil { - return nil, errors.Wrap(err, "Error converting numelements") - } - numElements = uint16(parsedNumElements) + offset, numElements, explicitRange, err := selectionOf(match, tagAddress, uint32(dataType.SizeInBytes())) + if err != nil { + return nil, err + } + byteOffset, err = checkByteOffset(uint64(byteOffset) + uint64(offset)) + if err != nil { + return nil, errors.Wrap(err, "Error applying the array selection") } if (transferSizeCode != 0) && (dataType.ShortName() != transferSizeCode) { @@ -416,9 +435,12 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { return nil, errors.New("A bit offset other than 0 is only supported for type BOOL") } - return NewTag(memoryArea, 0, byteOffset, bitOffset, numElements, dataType), nil + return NewTagWithShape(memoryArea, 0, byteOffset, bitOffset, numElements, dataType, explicitRange), nil } - return nil, errors.Errorf("Unable to parse %s", tagAddress) + // "%M100:INT[10]" - the count after the type - no longer parses, so say what to write + // instead of reporting only that nothing matched. + return nil, spiModel.InvalidAddressError(tagAddress, + "%area[selection]:TYPE - for example %M100[0..9]:INT") } func (m TagHandler) ParseQuery(query string) (apiModel.PlcQuery, error) { @@ -433,10 +455,69 @@ func checkDatablockNumber(blockNumber uint64) (uint16, error) { return uint16(blockNumber), nil } +// s7Constraints: an S7 address selects from one dimension. The protocol reads a contiguous byte +// range, so a selection is a start and a length, and nothing deeper than that fits. +var s7Constraints = spiModel.SingleDimension + +// selectionOf reads the array expression an address carries and returns how far into the memory +// area the selection starts, in bytes, and how many elements it spans. An address with no +// expression starts where it says and reads one element. +// +// The offset is consumed here rather than reported: an S7 address names a byte offset, so +// "%DB1.DBW20[4..7]" is the same read as "%DB1.DBW28[0..3]". What the caller sees afterwards is +// the resolved address, which is why GetArrayInfo reports the shape and not the written indices. +func selectionOf(match map[string]string, address string, bytesPerElement uint32) (uint64, uint16, bool, error) { + expression := match[ARRAY] + if expression == "" { + return 0, 1, false, nil + } + dimensions, err := spiModel.ParseArrayExpression(expression, address, s7Constraints) + if err != nil { + return 0, 0, false, err + } + dimension := dimensions[0] + elements := dimension.GetSize() + // The size the optimizer computes for the request must stay inside the addressable area, or + // it overflows before anyone looks at it. + elementSize := bytesPerElement + if elementSize < 1 { + elementSize = 1 + } + if elements < 1 || elements > (maxByteOffset+1)/elementSize { + return 0, 0, false, errors.Errorf("A tag of %d elements of %d bytes in '%s' spans more than the addressable %d bytes", + elements, elementSize, address, maxByteOffset+1) + } + // Widened before the multiplication and returned wide, so the caller's checkByteOffset sees + // the real offset: in uint32 an index the parser accepts (up to MaxInt32) times an eight byte + // type wraps, and 536870912 elements of eight bytes came out as zero - the original address, + // looking perfectly valid. + // The range flag is not derivable from the count: [4] and [4..4] both select one element. + return uint64(dimension.GetLowerBound()-dimension.GetBase()) * uint64(bytesPerElement), uint16(elements), + dimension.IsRange(), nil +} + +// bytesPerString is what one string of the given declared length occupies: the characters plus +// the two length bytes S7 puts in front of them, doubled for WSTRING. +func bytesPerString(dataType readWriteModel.TransportSize, stringLength uint16) uint32 { + perCharacter := uint32(1) + if dataType == readWriteModel.TransportSize_WSTRING { + perCharacter = 2 + } + return (uint32(stringLength) + 2) * perCharacter +} + +// assumedBytesPerVarLengthString is what one variable-length string is assumed to occupy. Its +// real length is only known once it has been read, so the optimizer sizes the request assuming +// the largest an S7 string can be, and a selection has to be measured against the same +// assumption. +func assumedBytesPerVarLengthString(dataType readWriteModel.TransportSize) uint32 { + return bytesPerString(dataType, 254) +} + func checkByteOffset(byteOffset uint64) (uint16, error) { // The generated S7AddressAny model limits the byte address to 16 bits, so anything // larger would silently truncate on the wire. - if byteOffset > 0xFFFF { + if byteOffset > maxByteOffset { return 0, errors.New("ByteOffset must fit into 16 bits (0..65535).") } return uint16(byteOffset), nil diff --git a/plc4go/internal/s7/TagHandler_test.go b/plc4go/internal/s7/TagHandler_test.go index 3cfc2d7385b..095dc28d266 100644 --- a/plc4go/internal/s7/TagHandler_test.go +++ b/plc4go/internal/s7/TagHandler_test.go @@ -59,7 +59,7 @@ func TestTagHandlerParseTag(t *testing.T) { assert.Equal(t, readWriteModel.TransportSize_STRING, stringTag.GetDataType()) }) t.Run("var length string long form with array", func(t *testing.T) { - tag, err := handler.ParseTag("%DB69.DBX68:WSTRING[3]") + tag, err := handler.ParseTag("%DB69.DBX68[0..2]:WSTRING") require.NoError(t, err) stringTag := tag.(PlcStringTag) assert.Equal(t, uint16(254), stringTag.stringLength) @@ -84,8 +84,8 @@ func TestTagHandlerParseTag(t *testing.T) { alarmTag = tag.(*AlarmTag) assert.Equal(t, readWriteModel.QueryType_ALARM_8, alarmTag.GetQueryType()) }) - t.Run("existing forms still parse", func(t *testing.T) { - for _, address := range []string{"%Q0.0:BOOL", "%M100:INT[10]", "%DB1.DBX0.0:BOOL", "%DB1:0.0:BOOL", "%I0:BYTE"} { + t.Run("the current forms parse", func(t *testing.T) { + for _, address := range []string{"%Q0.0:BOOL", "%M100[0..9]:INT", "%DB1.DBX0.0:BOOL", "%DB1:0.0:BOOL", "%I0:BYTE"} { _, err := handler.ParseTag(address) assert.NoError(t, err, address) } @@ -99,3 +99,150 @@ func TestTagHandlerParseTag(t *testing.T) { assert.Error(t, err) }) } + +// An S7 address names a byte offset, so a selection that starts past the declared base is +// A large index must be reported rather than wrapped. Scaling in uint32 turned 536870912 elements +// of an eight byte type into a byte offset of zero, so the tag resolved to the address it was +// written at and read the wrong location while looking entirely valid. +func TestTagHandler_ParseTag_rejectsASelectionOffsetThatOverflows(t *testing.T) { + handler := NewTagHandler() + _, err := handler.ParseTag("%DB1.DBW0[536870912]:LREAL") + assert.Error(t, err) +} + +// resolved into the address itself: "%DB1.DBW20[4..7]" is the same read as "%DB1.DBW28[0..3]", +// four words further in. The written indices are gone by the time the tag exists, which is why +// GetArrayInfo reports a shape rather than the indices - see Tag.GetArrayInfo. +func TestTagHandlerConsumesTheSelectionOffset(t *testing.T) { + handler := NewTagHandler() + + shifted, err := handler.ParseTag("%DB1.DBW20[4..7]:INT") + require.NoError(t, err) + equivalent, err := handler.ParseTag("%DB1.DBW28[0..3]:INT") + require.NoError(t, err) + assert.Equal(t, equivalent, shifted) + + // A declared base is what the offset is measured from, so [4..7;4] starts at the base and + // shifts nothing. + fromDeclaredBase, err := handler.ParseTag("%DB1.DBW20[4..7;4]:INT") + require.NoError(t, err) + unshifted, err := handler.ParseTag("%DB1.DBW20[0..3]:INT") + require.NoError(t, err) + assert.Equal(t, unshifted, fromDeclaredBase) + + // The shift is in elements, so it scales with the type's size: a DINT is four bytes. + dwords, err := handler.ParseTag("%DB1.DBD20[2..3]:DINT") + require.NoError(t, err) + assert.Equal(t, uint16(28), dwords.(PlcTag).GetByteOffset()) + assert.Equal(t, uint16(2), dwords.(PlcTag).GetNumElements()) +} + +// A bare index selects one element, which is a scalar - not a count of that many. +func TestTagHandlerReadsABareIndexAsOneElement(t *testing.T) { + handler := NewTagHandler() + + tag, err := handler.ParseTag("%DB1.DBW20[4]:INT") + require.NoError(t, err) + assert.Equal(t, uint16(1), tag.(PlcTag).GetNumElements()) + assert.Equal(t, uint16(28), tag.(PlcTag).GetByteOffset()) + assert.Empty(t, tag.GetArrayInfo(), "one element is a scalar") +} + +// Addresses written with the count after the type must fail, naming what to write instead. +func TestTagHandlerRejectsTheOldCountSuffix(t *testing.T) { + handler := NewTagHandler() + + for _, address := range []string{"%M100:INT[10]", "%DB1.DBW20:INT[4]", "%DB69.DBX68:WSTRING[3]", "%DB1:0:STRING(40)[3]"} { + t.Run(address, func(t *testing.T) { + _, err := handler.ParseTag(address) + require.Error(t, err, address) + assert.Contains(t, err.Error(), "invalid address", address) + }) + } +} + +// An S7 read is one contiguous byte range, so nothing deeper than a single dimension fits, and a +// selection may not span more than the addressable area. +func TestTagHandlerRejectsWhatS7CannotRead(t *testing.T) { + handler := NewTagHandler() + + _, err := handler.ParseTag("%DB1.DBW20[0..1][2..3]:INT") + assert.Error(t, err, "S7 reads one dimension") + + _, err = handler.ParseTag("%DB1.DBW0[0..40000]:INT") + assert.Error(t, err, "40001 INTs span more than the addressable 65536 bytes") +} + +// A rendered address must parse back to the same tag. It did not: the tag rendered as +// "0:INT[8]", which named neither the memory area nor the offset it read, and parsed as nothing. +func TestTagHandler_AddressStringRoundTrips(t *testing.T) { + handler := NewTagHandler() + + for _, address := range []string{ + "%M100[0..9]:INT", + "%DB1.DB20[0..3]:INT", + "%DB1.DB20:INT", + "%Q0.0:BOOL", + "%DB1.DB0.0:BOOL", + "%DB1.DB0[0..2]:STRING(20)", + "%DB69.DB68[0..2]:WSTRING(254)", + } { + t.Run(address, func(t *testing.T) { + tag, err := handler.ParseTag(address) + require.NoError(t, err) + assert.Equal(t, address, tag.GetAddressString()) + + reparsed, err := handler.ParseTag(tag.GetAddressString()) + require.NoError(t, err, "the rendered address must parse") + assert.Equal(t, tag, reparsed) + }) + } + + // The optional transfer size code is not part of the canonical form - it only repeats what + // the type already says - so an address carrying one renders without it and still re-parses + // to the same tag. + withSizeCode, err := handler.ParseTag("%DB69.DBX68[0..2]:WSTRING(254)") + require.NoError(t, err) + assert.Equal(t, "%DB69.DB68[0..2]:WSTRING(254)", withSizeCode.GetAddressString()) + reparsed, err := handler.ParseTag(withSizeCode.GetAddressString()) + require.NoError(t, err) + assert.Equal(t, withSizeCode, reparsed) + + // The data block is part of the address: a string tag used to be built with a hard-coded + // block number of 0, so this address read DB0 rather than DB69. + stringTag, err := handler.ParseTag("%DB69.DBX68:STRING(10)") + require.NoError(t, err) + assert.Equal(t, uint16(69), stringTag.(PlcTag).GetBlockNumber()) +} + +// A fixed-length string is read from the data block its address names. +// +// The long-form branch built the tag with a hard-coded block number of zero, so +// "%DB69.DBX68:STRING(10)" read DB0 and reported the result as though it had come from DB69 - +// wrong data, with nothing to suggest anything had gone wrong. Every other branch of ParseTag +// already parsed the block number; only this one, and the equivalent for WSTRING, did not. +// +// plc4j parses it (S7StringFixedLengthTag.of), so the two bindings disagreed about the same +// address. +func TestTagHandlerParsesTheBlockNumberOfAFixedLengthString(t *testing.T) { + handler := NewTagHandler() + + for _, c := range []struct { + address string + blockNumber uint16 + byteOffset uint16 + }{ + {"%DB69.DBX68:STRING(10)", 69, 68}, + {"%DB69.DBX68:WSTRING(10)", 69, 68}, + {"%DB1.DBX0:STRING(20)", 1, 0}, + // The short form was never affected; it is here so a regression in either is caught. + {"%DB69:68:STRING(10)", 69, 68}, + } { + t.Run(c.address, func(t *testing.T) { + tag, err := handler.ParseTag(c.address) + require.NoError(t, err) + assert.Equal(t, c.blockNumber, tag.(PlcTag).GetBlockNumber(), "the data block the address names") + assert.Equal(t, c.byteOffset, tag.(PlcTag).GetByteOffset()) + }) + } +} diff --git a/plc4go/internal/simulated/Driver_test.go b/plc4go/internal/simulated/Driver_test.go index 22904d6c0d4..5aa78254608 100644 --- a/plc4go/internal/simulated/Driver_test.go +++ b/plc4go/internal/simulated/Driver_test.go @@ -43,7 +43,7 @@ func TestDriver_CheckQuery(t *testing.T) { { name: "valid query", args: args{ - query: "STATE/test:UINT[2]", + query: "STATE/test[0..1]:UINT", }, wantErr: false, }, diff --git a/plc4go/internal/simulated/Tag.go b/plc4go/internal/simulated/Tag.go index b0cfe7c34ef..595554c38a5 100644 --- a/plc4go/internal/simulated/Tag.go +++ b/plc4go/internal/simulated/Tag.go @@ -41,14 +41,24 @@ type simulatedTag struct { Name string DataTypeSize model.SimulatedDataTypeSizes Quantity uint16 + // ExplicitRange records whether the address wrote the selection as a range. A one-element + // range is still a range - [4] is a scalar and [4..4] a list of one - which no count can say. + ExplicitRange bool } func NewSimulatedTag(tagType TagType, name string, dataTypeSize model.SimulatedDataTypeSizes, quantity uint16) Tag { + return NewSimulatedTagWithShape(tagType, name, dataTypeSize, quantity, quantity > 1) +} + +// NewSimulatedTagWithShape is NewSimulatedTag plus what the address said about its shape: a range +// is an array even when it spans one element, which the quantity alone cannot carry. +func NewSimulatedTagWithShape(tagType TagType, name string, dataTypeSize model.SimulatedDataTypeSizes, quantity uint16, explicitRange bool) Tag { return simulatedTag{ - TagType: tagType, - Name: name, - DataTypeSize: dataTypeSize, - Quantity: quantity, + ExplicitRange: explicitRange, + TagType: tagType, + Name: name, + DataTypeSize: dataTypeSize, + Quantity: quantity, } } @@ -65,7 +75,8 @@ func (t simulatedTag) GetDataTypeSize() model.SimulatedDataTypeSizes { } func (t simulatedTag) GetAddressString() string { - return fmt.Sprintf("%s/%s:%s[%d]", t.TagType.Name(), t.Name, t.DataTypeSize.String(), t.Quantity) + return fmt.Sprintf("%s/%s%s:%s", t.TagType.Name(), t.Name, + spiModel.RenderArrayExpression(t.GetArrayInfo()), t.DataTypeSize.String()) } func (t simulatedTag) GetValueType() values.PlcValueType { @@ -76,11 +87,13 @@ func (t simulatedTag) GetValueType() values.PlcValueType { } func (t simulatedTag) GetArrayInfo() []apiModel.ArrayInfo { - if t.Quantity != 1 { + // The flag decides the shape; the count only sizes it. + if t.ExplicitRange { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(t.Quantity), + UpperBound: uint32(t.Quantity) - 1, + Range: true, }, } } diff --git a/plc4go/internal/simulated/TagHandler.go b/plc4go/internal/simulated/TagHandler.go index e864e16b429..9121ce8acd3 100644 --- a/plc4go/internal/simulated/TagHandler.go +++ b/plc4go/internal/simulated/TagHandler.go @@ -22,11 +22,11 @@ package simulated import ( "fmt" "regexp" - "strconv" apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" "github.com/apache/plc4x/plc4go/protocols/simulated/readwrite/model" "github.com/apache/plc4x/plc4go/spi/errors" + spiModel "github.com/apache/plc4x/plc4go/spi/model" "github.com/apache/plc4x/plc4go/spi/utils" ) @@ -61,10 +61,42 @@ type TagHandler struct { func NewTagHandler() TagHandler { return TagHandler{ - simulatedQuery: regexp.MustCompile(`^(?P\w+)/(?P[a-zA-Z0-9_\\.]+):(?P[a-zA-Z0-9]+)(\[(?P\d+)])?$`), + // The selection sits between the name and the type, as it does in plc4j's + // SimulatedTag.ADDRESS_PATTERN: "RANDOM/foo[0..3]:INT". + simulatedQuery: regexp.MustCompile(`^(?P\w+)/(?P[a-zA-Z0-9_\\.]+)` + spiModel.ArrayGroupPattern + `:(?P[a-zA-Z0-9]+)$`), } } +// elementsOf resolves an address's array expression to a number of elements. This driver +// addresses a named variable rather than a numeric offset, so a selection that does not start at +// the first element has nothing to apply to and is reported rather than quietly ignored. +// +// The count is carried as a uint16 from here on, so it is bounded as one: an unchecked cast +// turned a count above 65535 into whatever the low two bytes happened to be, handing back a tag +// of 4464 elements for a request of 70000 and an empty one for 65536. +func elementsOf(expression string, tagAddress string) (uint16, bool, error) { + if expression == "" { + return 1, false, nil + } + dimensions, err := spiModel.ParseArrayExpression(expression, tagAddress, spiModel.SingleDimension) + if err != nil { + return 0, false, err + } + dimension := dimensions[0] + if dimension.GetLowerBound() != dimension.GetBase() { + return 0, false, errors.Errorf("Array selection '%s' in tag '%s' must start at the first element: "+ + "this driver addresses a named variable, so there is no offset to start from", + expression, tagAddress) + } + elements := dimension.GetSize() + if elements < 1 || elements > 0xFFFF { + return 0, false, errors.Errorf("A tag of %d elements in '%s' is more than a simulated tag may hold", + elements, tagAddress) + } + // A range is an array even when it spans one element, which the count cannot say. + return uint16(elements), dimension.IsRange(), nil +} + func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { if match := utils.GetSubgroupMatches(m.simulatedQuery, tagAddress); match != nil { tagTypeName, ok := match["type"] @@ -92,24 +124,16 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { return nil, errors.New("unknown tag data-type '" + tagDataTypeName + "'") } } - tagNumElementsText, ok := match["numElements"] - tagNumElements := uint16(1) - if ok && len(tagNumElementsText) > 0 { - // The count is carried as a uint16 from here on, so parse it as one: an Atoi followed by a - // cast turned a count above 65535 into whatever the low two bytes happened to be, handing - // back a tag of 4464 elements for a request of 70000 and an empty one for 65536. - num, err := strconv.ParseUint(tagNumElementsText, 10, 16) - if err != nil { - return nil, errors.Wrapf(err, "invalid size '%s'", tagNumElementsText) - } - if num == 0 { - return nil, errors.New("the number of elements must be greater than zero") - } - tagNumElements = uint16(num) + tagNumElements, explicitRange, err := elementsOf(match["array"], tagAddress) + if err != nil { + return nil, err } - return NewSimulatedTag(tagType, tagName, tagDataType, tagNumElements), nil + return NewSimulatedTagWithShape(tagType, tagName, tagDataType, tagNumElements, explicitRange), nil } - return nil, errors.New("Invalid address format for address '" + tagAddress + "'") + // "RANDOM/foo:INT[4]" - the count after the type - no longer parses, so name the form to + // write rather than reporting only that nothing matched. + return nil, spiModel.InvalidAddressError(tagAddress, + "{type}/{name}[selection]:{TYPE} - for example RANDOM/foo[0..3]:INT") } func (m TagHandler) ParseQuery(query string) (apiModel.PlcQuery, error) { diff --git a/plc4go/internal/simulated/TagHandler_test.go b/plc4go/internal/simulated/TagHandler_test.go index eab019a1845..00dd989889f 100644 --- a/plc4go/internal/simulated/TagHandler_test.go +++ b/plc4go/internal/simulated/TagHandler_test.go @@ -23,6 +23,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" readWriteModel "github.com/apache/plc4x/plc4go/protocols/simulated/readwrite/model" @@ -49,7 +50,7 @@ func TestFieldHandler_ParseQuery(t *testing.T) { { name: "simple random array", args: args{ - query: "RANDOM/test_random:BOOL[10]", + query: "RANDOM/test_random[0..9]:BOOL", }, want: NewSimulatedTag(TagRandom, "test_random", readWriteModel.SimulatedDataTypeSizes_BOOL, 10), wantErr: false, @@ -65,7 +66,7 @@ func TestFieldHandler_ParseQuery(t *testing.T) { { name: "simple state array", args: args{ - query: "STATE/test_state:BOOL[42]", + query: "STATE/test_state[0..41]:BOOL", }, want: NewSimulatedTag(TagState, "test_state", readWriteModel.SimulatedDataTypeSizes_BOOL, 42), wantErr: false, @@ -81,7 +82,7 @@ func TestFieldHandler_ParseQuery(t *testing.T) { { name: "simple stdout array", args: args{ - query: "STDOUT/test_stdout:BOOL[23]", + query: "STDOUT/test_stdout[0..22]:BOOL", }, want: NewSimulatedTag(TagStdOut, "test_stdout", readWriteModel.SimulatedDataTypeSizes_BOOL, 23), wantErr: false, @@ -105,7 +106,7 @@ func TestFieldHandler_ParseQuery(t *testing.T) { { name: "error invalid datatype", args: args{ - query: "RANDOM/test_stdout:HURZ[23]", + query: "RANDOM/test_stdout[0..22]:HURZ", }, want: nil, wantErr: true, @@ -113,7 +114,7 @@ func TestFieldHandler_ParseQuery(t *testing.T) { { name: "error invalid array size", args: args{ - query: "RANDOM/test_stdout:BOOL[999999999999999999999999999999999999]", + query: "RANDOM/test_stdout[0..999999999999999999999999999999999998]:BOOL", }, want: nil, wantErr: true, @@ -123,7 +124,7 @@ func TestFieldHandler_ParseQuery(t *testing.T) { // bytes of what was asked for - a tag of 4464 elements rather than an error. name: "error array size past the count a tag can carry", args: args{ - query: "RANDOM/test_stdout:BOOL[70000]", + query: "RANDOM/test_stdout[0..69999]:BOOL", }, want: nil, wantErr: true, @@ -133,15 +134,17 @@ func TestFieldHandler_ParseQuery(t *testing.T) { // holding nothing at all. name: "error array size one past the count a tag can carry", args: args{ - query: "RANDOM/test_stdout:BOOL[65536]", + query: "RANDOM/test_stdout[0..65535]:BOOL", }, want: nil, wantErr: true, }, { - name: "error array size of zero", + // A count of zero has no spelling in the notation - a range is written with the + // indices it covers - but an inverted one is still nonsense. + name: "error inverted range", args: args{ - query: "RANDOM/test_stdout:BOOL[0]", + query: "RANDOM/test_stdout[3..1]:BOOL", }, want: nil, wantErr: true, @@ -149,7 +152,7 @@ func TestFieldHandler_ParseQuery(t *testing.T) { { name: "largest array size a tag can carry", args: args{ - query: "RANDOM/test_stdout:BOOL[65535]", + query: "RANDOM/test_stdout[0..65534]:BOOL", }, want: NewSimulatedTag(TagRandom, "test_stdout", readWriteModel.SimulatedDataTypeSizes_BOOL, 65535), wantErr: false, @@ -205,3 +208,20 @@ func TestFieldType_Name(t *testing.T) { }) } } + +// This driver addresses a named variable rather than a numeric offset, so a selection that does +// not start at the first element has nothing to apply to. Reading it as though it did would hand +// back the first elements under the impression they were the requested ones. +func TestFieldHandler_ParseTagRejectsASelectionWithAnOffset(t *testing.T) { + handler := NewTagHandler() + + _, err := handler.ParseTag("RANDOM/test[4..7]:INT") + require.Error(t, err) + assert.Contains(t, err.Error(), "must start at the first element") + + // A declared base is what the offset is measured from, so [4..7;4] does start at the first + // element and is accepted. + fromDeclaredBase, err := handler.ParseTag("RANDOM/test[4..7;4]:INT") + require.NoError(t, err) + assert.Equal(t, uint16(4), fromDeclaredBase.(simulatedTag).Quantity) +} diff --git a/plc4go/internal/simulated/Tag_test.go b/plc4go/internal/simulated/Tag_test.go index 3db5b0a834d..04a2e823324 100644 --- a/plc4go/internal/simulated/Tag_test.go +++ b/plc4go/internal/simulated/Tag_test.go @@ -84,7 +84,7 @@ func TestSimulatedField_GetAddressString(t1 *testing.T) { DataTypeSize: model.SimulatedDataTypeSizes_BOOL, Quantity: 1, }, - want: "RANDOM/test:BOOL[1]", + want: "RANDOM/test:BOOL", }, } for _, tt := range tests { diff --git a/plc4go/internal/slmp/Connection_test.go b/plc4go/internal/slmp/Connection_test.go index d74598e7b15..401b417ddf2 100644 --- a/plc4go/internal/slmp/Connection_test.go +++ b/plc4go/internal/slmp/Connection_test.go @@ -123,7 +123,7 @@ func readTag(t *testing.T, address string, endCode uint16, responseData []byte) // on the wire for a given tag. func TestConnection_ReadBuildsABatchReadFrame(t *testing.T) { // D350 as two words is the SH-080008 Batch Read worked example. - _, frame := readTag(t, "D350:WORD[2]", 0x0000, []byte{0xAB, 0x56, 0x0F, 0x17}) + _, frame := readTag(t, "D350[0..1]:WORD", 0x0000, []byte{0xAB, 0x56, 0x0F, 0x17}) assert.Equal(t, commandBatchRead, frame.GetCommand()) assert.Equal(t, subCommandWordUnits, frame.GetSubCommand(), "this version only speaks word units") @@ -138,7 +138,7 @@ func TestConnection_ReadBuildsABatchReadFrame(t *testing.T) { // TestConnection_ReadAsksForWordsNotElements is the one arithmetic mistake that would send a frame // half the size it needs: a REAL is two words, so a REAL[4] tag has to ask for eight points. func TestConnection_ReadAsksForWordsNotElements(t *testing.T) { - _, frame := readTag(t, "R200:REAL[4]", 0x0000, make([]byte, 16)) + _, frame := readTag(t, "R200[0..3]:REAL", 0x0000, make([]byte, 16)) readRequest, ok := frame.GetRequestData().(readWriteModel.SlmpReadRequest) require.True(t, ok) assert.Equal(t, uint16(8), readRequest.GetNumberOfPoints()) @@ -179,7 +179,7 @@ func TestConnection_ReadDecodesTheResponse(t *testing.T) { }, }, { - name: "two WORDs", address: "D350:WORD[2]", responseData: []byte{0xAB, 0x56, 0x0F, 0x17}, + name: "two WORDs", address: "D350[0..1]:WORD", responseData: []byte{0xAB, 0x56, 0x0F, 0x17}, assert: func(t *testing.T, response apiModel.PlcReadResponse) { value := response.GetValue("hurz") require.True(t, value.IsList()) @@ -235,7 +235,7 @@ func TestConnection_ReadMapsAShortPayload(t *testing.T) { {name: "nothing at all", address: "D350", responseData: nil}, {name: "half a word", address: "D350", responseData: []byte{0x01}}, {name: "one word for a REAL", address: "D350:REAL", responseData: []byte{0x01, 0x02}}, - {name: "one word short of a list", address: "D350:WORD[2]", responseData: []byte{0x01, 0x02}}, + {name: "one word short of a list", address: "D350[0..1]:WORD", responseData: []byte{0x01, 0x02}}, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { @@ -355,7 +355,7 @@ func writeTag(t *testing.T, address string, value any, endCode uint16, responseD } func TestConnection_WriteBuildsABatchWriteFrame(t *testing.T) { - response, frame := writeTag(t, "D350:WORD[2]", []uint16{0x1234, 0x5678}, 0x0000, nil) + response, frame := writeTag(t, "D350[0..1]:WORD", []uint16{0x1234, 0x5678}, 0x0000, nil) assert.Equal(t, apiModel.PlcResponseCode_OK, response.GetResponseCode("hurz")) assert.Equal(t, commandBatchWrite, frame.GetCommand()) @@ -383,7 +383,7 @@ func TestConnection_WriteEncodesTheValue(t *testing.T) { {name: "a DINT over two words", address: "D350:DINT", value: int32(-2), want: []byte{0xFE, 0xFF, 0xFF, 0xFF}}, {name: "a REAL over two words", address: "D350:REAL", value: float32(1.0), want: []byte{0x00, 0x00, 0x80, 0x3F}}, { - name: "a list of REALs", address: "D350:REAL[2]", value: []float32{1.0, 2.0}, + name: "a list of REALs", address: "D350[0..1]:REAL", value: []float32{1.0, 2.0}, want: []byte{0x00, 0x00, 0x80, 0x3F, 0x00, 0x00, 0x00, 0x40}, }, } @@ -423,9 +423,9 @@ func TestConnection_WriteBuilderRefusesAnUncoercibleValue(t *testing.T) { }{ {name: "a negative value for a WORD", address: "D350:WORD", value: int32(-1)}, {name: "a string for a REAL", address: "D350:REAL", value: "hurz"}, - {name: "too few values for an array tag", address: "D350:WORD[3]", value: []uint16{1, 2}}, - {name: "too many values for an array tag", address: "D350:WORD[2]", value: []uint16{1, 2, 3}}, - {name: "a scalar for an array tag", address: "D350:WORD[2]", value: uint16(1)}, + {name: "too few values for an array tag", address: "D350[0..2]:WORD", value: []uint16{1, 2}}, + {name: "too many values for an array tag", address: "D350[0..1]:WORD", value: []uint16{1, 2, 3}}, + {name: "a scalar for an array tag", address: "D350[0..1]:WORD", value: uint16(1)}, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { @@ -451,10 +451,10 @@ func TestConnection_WriteRefusesAnUnencodableValue(t *testing.T) { }{ {name: "a negative value for a WORD", address: "D350:WORD", value: spiValues.NewPlcDINT(-1)}, { - name: "too few values for an array tag", address: "D350:WORD[3]", + name: "too few values for an array tag", address: "D350[0..2]:WORD", value: spiValues.NewPlcList([]apiValues.PlcValue{spiValues.NewPlcWORD(1), spiValues.NewPlcWORD(2)}), }, - {name: "a scalar for an array tag", address: "D350:WORD[2]", value: spiValues.NewPlcWORD(1)}, + {name: "a scalar for an array tag", address: "D350[0..1]:WORD", value: spiValues.NewPlcWORD(1)}, {name: "no value at all", address: "D350:WORD", value: nil}, } for _, testCase := range tests { diff --git a/plc4go/internal/slmp/Driver_test.go b/plc4go/internal/slmp/Driver_test.go index 4a3d3b243b5..b6c88dab6e6 100644 --- a/plc4go/internal/slmp/Driver_test.go +++ b/plc4go/internal/slmp/Driver_test.go @@ -57,7 +57,7 @@ func TestDriver_Identity(t *testing.T) { tag, err := driver.GetPlcTagHandler().ParseTag("D350") require.NoError(t, err) - assert.Equal(t, "D350:WORD[1]", tag.GetAddressString()) + assert.Equal(t, "D350:WORD", tag.GetAddressString()) } // TestDriver_DefaultPortComesFromTheMspec keeps the default port and the wire spec from drifting diff --git a/plc4go/internal/slmp/Subscriber_test.go b/plc4go/internal/slmp/Subscriber_test.go index f1ccd918102..540201a5e61 100644 --- a/plc4go/internal/slmp/Subscriber_test.go +++ b/plc4go/internal/slmp/Subscriber_test.go @@ -113,7 +113,7 @@ func TestConnection_SubscribeCyclicPollsTheReadPath(t *testing.T) { assert.Equal(t, apiModel.PlcResponseCode_OK, event.GetResponseCode("hurz")) // The address the poller polled has to be the address the tag spells, which is what makes // the round trip of GetAddressString load bearing. - assert.Equal(t, "D350:WORD[1]", event.GetAddress("hurz")) + assert.Equal(t, "D350:WORD", event.GetAddress("hurz")) require.NotNil(t, event.GetValue("hurz")) assert.Positive(t, event.GetValue("hurz").GetUint16(), "the poll has to carry the polled value") case <-time.After(5 * time.Second): diff --git a/plc4go/internal/slmp/Tag.go b/plc4go/internal/slmp/Tag.go index db5adf727f1..2af29979ec7 100644 --- a/plc4go/internal/slmp/Tag.go +++ b/plc4go/internal/slmp/Tag.go @@ -72,16 +72,26 @@ type plcTag struct { DeviceNumber uint32 DataType DataType Quantity uint16 + // ExplicitRange records whether the address wrote the selection as a range. A one-element + // range is still a range - [4] is a scalar and [4..4] a list of one - which no count can say. + ExplicitRange bool } var _ PlcTag = plcTag{} func NewTag(deviceCode readWriteModel.SlmpDeviceCode, deviceNumber uint32, dataType DataType, quantity uint16) PlcTag { + return NewTagWithShape(deviceCode, deviceNumber, dataType, quantity, quantity > 1) +} + +// NewTagWithShape is NewTag plus what the address said about its shape: a range is an array even +// when it spans one element, which the quantity alone cannot carry. +func NewTagWithShape(deviceCode readWriteModel.SlmpDeviceCode, deviceNumber uint32, dataType DataType, quantity uint16, explicitRange bool) PlcTag { return plcTag{ - DeviceCode: deviceCode, - DeviceNumber: deviceNumber, - DataType: dataType, - Quantity: quantity, + ExplicitRange: explicitRange, + DeviceCode: deviceCode, + DeviceNumber: deviceNumber, + DataType: dataType, + Quantity: quantity, } } @@ -133,24 +143,28 @@ func (m plcTag) GetAddressString() string { // Unlike plc4j's SlmpTag.getAddressString, the data type is always spelled out. plc4j omits it // for a WORD tag of quantity one and spells it for every other tag, which round-trips too, but // an address that always names its type is the one a reader of a log line can act on. - return fmt.Sprintf("%s%s:%s[%d]", m.DeviceCode, address, m.DataType, m.Quantity) + return fmt.Sprintf("%s%s%s:%s", m.DeviceCode, address, + spiModel.RenderArrayExpression(m.GetArrayInfo()), m.DataType) } func (m plcTag) GetValueType() apiValues.PlcValueType { return m.DataType.GetValueType() } -// GetArrayInfo reports the number of elements as a half-open range [0, Quantity). +// GetArrayInfo reports the shape of the value the caller receives, as an inclusive range: a +// quantity of 5 yields [0..4], the same as plc4j. // -// plc4j's SlmpTag returns an inclusive upper bound (quantity - 1); the Go SPI uses the opposite -// convention - spiModel.DefaultArrayInfo.GetSize is UpperBound - LowerBound - so a quantity of 5 -// yields [0, 5) here and not [0, 4]. +// The indices are relative to what the caller receives, not to what was written: an SLMP address +// is a device number, so the driver folds the start of the selection into it when it resolves +// the address. func (m plcTag) GetArrayInfo() []apiModel.ArrayInfo { - if m.Quantity != 1 { + // The flag decides the shape; the count only sizes it. + if m.ExplicitRange { return []apiModel.ArrayInfo{ &spiModel.DefaultArrayInfo{ LowerBound: 0, - UpperBound: uint32(m.Quantity), + UpperBound: uint32(m.Quantity) - 1, + Range: true, }, } } diff --git a/plc4go/internal/slmp/TagHandler.go b/plc4go/internal/slmp/TagHandler.go index 2f5088ce032..a9e8b305f21 100644 --- a/plc4go/internal/slmp/TagHandler.go +++ b/plc4go/internal/slmp/TagHandler.go @@ -27,6 +27,7 @@ import ( apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" readWriteModel "github.com/apache/plc4x/plc4go/protocols/slmp/readwrite/model" "github.com/apache/plc4x/plc4go/spi/errors" + spiModel "github.com/apache/plc4x/plc4go/spi/model" "github.com/apache/plc4x/plc4go/spi/utils" ) @@ -65,14 +66,19 @@ type TagHandler struct { func NewTagHandler() TagHandler { return TagHandler{ - addressPattern: regexp.MustCompile(`^(?P[A-Za-z]+?)(?P0[xX])?(?P
[0-9A-Fa-f]+)(:(?P[A-Za-z_]+))?(\[(?P\d+)])?$`), + // The selection sits between the address and the type, as it does in plc4j's + // SlmpTag.ADDRESS_PATTERN: "D100[0..3]:INT". + addressPattern: regexp.MustCompile(`^(?P[A-Za-z]+?)(?P0[xX])?(?P
[0-9A-Fa-f]+)` + spiModel.ArrayGroupPattern + `(:(?P[A-Za-z_]+))?$`), } } func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { match := utils.GetSubgroupMatches(m.addressPattern, tagAddress) if match == nil { - return nil, errors.Errorf("Unable to parse SLMP address: %s", tagAddress) + // "D100:INT[4]" - the count after the type - no longer parses, so name the form to + // write rather than reporting only that nothing matched. + return nil, spiModel.InvalidAddressError(tagAddress, + "{device}{address}[selection]:{TYPE} - for example D100[0..3]:INT") } deviceToken := strings.ToUpper(match["device"]) @@ -110,16 +116,28 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { } } - // Parsed as 32 bit, which is well past the point ceiling checked below and keeps the - // multiplication that computes the point count from overflowing. + // The selection's offset moves the device number and its size is how many devices are read, + // so "D100[4..7]" is the same read as "D104[0..3]". An SLMP Batch Read covers one contiguous + // run of devices, so nothing deeper than a single dimension fits. quantity := uint64(1) - if quantityToken := match["quantity"]; quantityToken != "" { - if quantity, err = strconv.ParseUint(quantityToken, 10, 32); err != nil { - return nil, errors.Wrapf(err, "quantity out of range in: %s", tagAddress) + explicitRange := false + if expression := match["array"]; expression != "" { + dimensions, err := spiModel.ParseArrayExpression(expression, tagAddress, spiModel.SingleDimension) + if err != nil { + return nil, err } - if quantity < 1 { - return nil, errors.Errorf("quantity must be >= 1 in: %s", tagAddress) + dimension := dimensions[0] + // The offset counts elements; a device number counts 16-bit words. They coincide only for + // a one-word type, so D100[4]:DINT would otherwise land on D104 instead of D108. This is + // the same scale the point count below applies, so offset and length cannot disagree. + deviceNumber += uint64(dimension.GetLowerBound()-dimension.GetBase()) * uint64(dataType.WordsPerElement()) + if deviceNumber > maxDeviceNumber { + return nil, errors.Errorf("device number %d exceeds the 24-bit SLMP device-address range [0..%d]: %s", + deviceNumber, maxDeviceNumber, tagAddress) } + quantity = uint64(dimension.GetSize()) + // A range is an array even when it spans one element, which the count cannot say. + explicitRange = dimension.IsRange() } // The point count is what the frame asks for, so it - not the quantity - is what has to stay @@ -130,7 +148,7 @@ func (m TagHandler) ParseTag(tagAddress string) (apiModel.PlcTag, error) { numberOfPoints, maxPoints, tagAddress) } - return NewTag(device.deviceCode, uint32(deviceNumber), dataType, uint16(quantity)), nil + return NewTagWithShape(device.deviceCode, uint32(deviceNumber), dataType, uint16(quantity), explicitRange), nil } // ParseQuery is not supported: neither this driver nor plc4j's browses an SLMP device. plc4j's diff --git a/plc4go/internal/slmp/TagHandler_test.go b/plc4go/internal/slmp/TagHandler_test.go index e53f2bbe3c5..bb16d29d2fa 100644 --- a/plc4go/internal/slmp/TagHandler_test.go +++ b/plc4go/internal/slmp/TagHandler_test.go @@ -53,13 +53,13 @@ func TestTagHandler_ParseTag(t *testing.T) { wantDataType: DataTypeWORD, wantQuantity: 1, wantNumberOfPoints: 1, }, { - name: "R is addressed in decimal", address: "R200:REAL[4]", + name: "R is addressed in decimal", address: "R200[0..3]:REAL", wantDeviceCode: readWriteModel.SlmpDeviceCode_R, wantDeviceNumber: 200, wantDataType: DataTypeREAL, wantQuantity: 4, wantNumberOfPoints: 8, }, { // W is a link register, which MELSEC addresses in hex (SH-080008 section 8.1). - name: "W is addressed in hex", address: "W1A:WORD[10]", + name: "W is addressed in hex", address: "W1A[0..9]:WORD", wantDeviceCode: readWriteModel.SlmpDeviceCode_W, wantDeviceNumber: 0x1A, wantDataType: DataTypeWORD, wantQuantity: 10, wantNumberOfPoints: 10, }, @@ -82,7 +82,7 @@ func TestTagHandler_ParseTag(t *testing.T) { wantDataType: DataTypeWORD, wantQuantity: 1, wantNumberOfPoints: 1, }, { - name: "an all-letter W address", address: "WABCD:INT[2]", + name: "an all-letter W address", address: "WABCD[0..1]:INT", wantDeviceCode: readWriteModel.SlmpDeviceCode_W, wantDeviceNumber: 0xABCD, wantDataType: DataTypeINT, wantQuantity: 2, wantNumberOfPoints: 2, }, @@ -92,7 +92,7 @@ func TestTagHandler_ParseTag(t *testing.T) { wantDataType: DataTypeINT, wantQuantity: 1, wantNumberOfPoints: 1, }, { - name: "a double-word type takes two points per element", address: "D0:DINT[3]", + name: "a double-word type takes two points per element", address: "D0[0..2]:DINT", wantDeviceCode: readWriteModel.SlmpDeviceCode_D, wantDeviceNumber: 0, wantDataType: DataTypeDINT, wantQuantity: 3, wantNumberOfPoints: 6, }, @@ -102,7 +102,7 @@ func TestTagHandler_ParseTag(t *testing.T) { wantDataType: DataTypeUDINT, wantQuantity: 1, wantNumberOfPoints: 2, }, { - name: "UINT is a single word", address: "D0:UINT[8]", + name: "UINT is a single word", address: "D0[0..7]:UINT", wantDeviceCode: readWriteModel.SlmpDeviceCode_D, wantDeviceNumber: 0, wantDataType: DataTypeUINT, wantQuantity: 8, wantNumberOfPoints: 8, }, @@ -112,12 +112,12 @@ func TestTagHandler_ParseTag(t *testing.T) { wantDataType: DataTypeWORD, wantQuantity: 1, wantNumberOfPoints: 1, }, { - name: "the largest single-frame word transfer", address: "D0:WORD[960]", + name: "the largest single-frame word transfer", address: "D0[0..959]:WORD", wantDeviceCode: readWriteModel.SlmpDeviceCode_D, wantDeviceNumber: 0, wantDataType: DataTypeWORD, wantQuantity: 960, wantNumberOfPoints: 960, }, { - name: "which is half as many double-word elements", address: "D0:REAL[480]", + name: "which is half as many double-word elements", address: "D0[0..479]:REAL", wantDeviceCode: readWriteModel.SlmpDeviceCode_D, wantDeviceNumber: 0, wantDataType: DataTypeREAL, wantQuantity: 480, wantNumberOfPoints: 960, }, @@ -161,15 +161,17 @@ func TestTagHandler_ParseTagRejects(t *testing.T) { {name: "a decimal device with hex digits", address: "DAB"}, {name: "an unknown data type", address: "D350:LREAL"}, {name: "a data type nobody wrote", address: "D350:"}, - {name: "a quantity of zero", address: "D350:WORD[0]"}, + // There is no way to ask for zero devices any more - a range is written with the + // indices it covers - but an inverted one is still nonsense. + {name: "an inverted range", address: "D350[3..1]:WORD"}, {name: "a negative quantity", address: "D350:WORD[-1]"}, {name: "an empty quantity", address: "D350:WORD[]"}, {name: "a device number beyond the 24-bit field", address: "D16777216"}, // There is no request optimizer to split a bigger transfer into several frames, so a tag // that would need one is refused rather than sent as a frame the device rejects. - {name: "more words than one frame carries", address: "D0:WORD[961]"}, - {name: "more double words than one frame carries", address: "D0:REAL[481]"}, - {name: "trailing junk", address: "D350:WORD[1]x"}, + {name: "more words than one frame carries", address: "D0[0..960]:WORD"}, + {name: "more double words than one frame carries", address: "D0[0..480]:REAL"}, + {name: "trailing junk", address: "D350:WORDx"}, } handler := NewTagHandler() for _, testCase := range tests { @@ -194,8 +196,8 @@ func TestTagHandler_ParseQuery(t *testing.T) { // to the same tag would silently poll something else. func TestTag_AddressStringRoundTrips(t *testing.T) { addresses := []string{ - "D350", "D350:INT", "D350:WORD[4]", "R200:REAL[4]", "W1A:WORD[10]", "W0x1A", - "WAB", "D0:DINT[3]", "D0:UDINT", "D0:UINT[8]", "D16777215", + "D350", "D350:INT", "D350[0..3]:WORD", "R200[0..3]:REAL", "W1A[0..9]:WORD", "W0x1A", + "WAB", "D0[0..2]:DINT", "D0:UDINT", "D0[0..7]:UINT", "D16777215", } handler := NewTagHandler() for _, address := range addresses { @@ -216,10 +218,10 @@ func TestTag_AddressStringSpelling(t *testing.T) { tag PlcTag want string }{ - {tag: NewTag(readWriteModel.SlmpDeviceCode_D, 350, DataTypeWORD, 1), want: "D350:WORD[1]"}, - {tag: NewTag(readWriteModel.SlmpDeviceCode_R, 200, DataTypeREAL, 4), want: "R200:REAL[4]"}, + {tag: NewTag(readWriteModel.SlmpDeviceCode_D, 350, DataTypeWORD, 1), want: "D350:WORD"}, + {tag: NewTag(readWriteModel.SlmpDeviceCode_R, 200, DataTypeREAL, 4), want: "R200[0..3]:REAL"}, // A W address is written in hex, because that is how the handler reads one back. - {tag: NewTag(readWriteModel.SlmpDeviceCode_W, 0x1A, DataTypeINT, 2), want: "W0x1A:INT[2]"}, + {tag: NewTag(readWriteModel.SlmpDeviceCode_W, 0x1A, DataTypeINT, 2), want: "W0x1A[0..1]:INT"}, } for _, testCase := range tests { t.Run(testCase.want, func(t *testing.T) { @@ -236,13 +238,13 @@ func TestTag_Metadata(t *testing.T) { assert.Equal(t, apiValues.INT, scalar.GetValueType()) assert.Empty(t, scalar.GetArrayInfo(), "a scalar tag has no array info") - array, err := handler.ParseTag("R200:REAL[4]") + array, err := handler.ParseTag("R200[0..3]:REAL") require.NoError(t, err) assert.Equal(t, apiValues.REAL, array.GetValueType()) require.Len(t, array.GetArrayInfo(), 1) - // The Go SPI's upper bound is exclusive, so four elements are [0, 4) and the size is 4. + // Both bounds are inclusive, so four elements are 0..3 and the size is 4. assert.Equal(t, uint32(0), array.GetArrayInfo()[0].GetLowerBound()) - assert.Equal(t, uint32(4), array.GetArrayInfo()[0].GetUpperBound()) + assert.Equal(t, uint32(3), array.GetArrayInfo()[0].GetUpperBound()) assert.Equal(t, uint32(4), array.GetArrayInfo()[0].GetSize()) // Every slmp tag is usable as a subscription tag, because subscriptions are emulated by polling @@ -273,3 +275,62 @@ func TestCastToSlmpTagFromPlcTag(t *testing.T) { assert.Error(t, err) assert.Nil(t, foreign) } + +// An SLMP address is a device number, so a selection that starts past the declared base is +// resolved into the address itself: "D100[4..7]" is the same read as "D104[0..3]". +func TestTagHandler_ParseTag_consumesTheSelectionOffset(t *testing.T) { + handler := NewTagHandler() + + shifted, err := handler.ParseTag("D100[4..7]:INT") + require.NoError(t, err) + equivalent, err := handler.ParseTag("D104[0..3]:INT") + require.NoError(t, err) + assert.Equal(t, equivalent, shifted) + assert.Equal(t, "D104[0..3]:INT", shifted.GetAddressString()) + + // A declared base is what the offset is measured from, so [4..7;4] shifts nothing. + fromDeclaredBase, err := handler.ParseTag("D100[4..7;4]:INT") + require.NoError(t, err) + unshifted, err := handler.ParseTag("D100[0..3]:INT") + require.NoError(t, err) + assert.Equal(t, unshifted, fromDeclaredBase) + + // A bare index selects the device at that index, not that many devices. + single, err := handler.ParseTag("D100[4]:INT") + require.NoError(t, err) + assert.Equal(t, "D104:INT", single.GetAddressString()) + assert.Empty(t, single.GetArrayInfo(), "one device is a scalar") +} + +// Addresses written with the count after the type must fail, naming what to write instead. +func TestTagHandler_ParseTag_rejectsTheOldCountSuffix(t *testing.T) { + handler := NewTagHandler() + + for _, address := range []string{"D350:WORD[2]", "R200:REAL[4]", "W1A:WORD[10]"} { + t.Run(address, func(t *testing.T) { + _, err := handler.ParseTag(address) + require.Error(t, err, address) + assert.Contains(t, err.Error(), "invalid address", address) + }) + } +} + +// A selection offset counts elements; an SLMP device number counts 16-bit words. The point count +// already scales by WordsPerElement, so an unscaled offset moved the read rather than shortening it. +func TestTagHandler_ParseTag_scalesTheOffsetByTheWordsPerElement(t *testing.T) { + handler := NewTagHandler() + + oneWord, err := handler.ParseTag("D100[4]:INT") + require.NoError(t, err) + assert.Equal(t, "D104:INT", oneWord.GetAddressString()) + + // The fifth DINT begins eight words along, at D108. + twoWords, err := handler.ParseTag("D100[4]:DINT") + require.NoError(t, err) + assert.Equal(t, "D108:DINT", twoWords.GetAddressString()) + + // A declared base is measured in elements too, so [4..7;4] shifts nothing. + fromBase, err := handler.ParseTag("D100[4..7;4]:DINT") + require.NoError(t, err) + assert.Equal(t, "D100[0..3]:DINT", fromBase.GetAddressString()) +} diff --git a/plc4go/internal/slmp/endToEnd_test.go b/plc4go/internal/slmp/endToEnd_test.go index 4cef6adb324..b0167e8cffa 100644 --- a/plc4go/internal/slmp/endToEnd_test.go +++ b/plc4go/internal/slmp/endToEnd_test.go @@ -82,7 +82,7 @@ func pushFrame(t *testing.T, transportInstance *test.TransportInstance, frame re func TestEndToEnd_ReadThroughTheRunningCodec(t *testing.T) { connection, transportInstance := newRunningConnection(t) - readRequest, err := connection.ReadRequestBuilder().AddTagAddress("registers", "D350:WORD[2]").Build() + readRequest, err := connection.ReadRequestBuilder().AddTagAddress("registers", "D350[0..1]:WORD").Build() require.NoError(t, err) results := readRequest.Execute(testutils.TestContext(t)) @@ -110,7 +110,7 @@ func TestEndToEnd_WriteThroughTheRunningCodec(t *testing.T) { connection, transportInstance := newRunningConnection(t) writeRequest, err := connection.WriteRequestBuilder(). - AddTagAddress("registers", "D350:WORD[2]", []uint16{0x1234, 0x5678}).Build() + AddTagAddress("registers", "D350[0..1]:WORD", []uint16{0x1234, 0x5678}).Build() require.NoError(t, err) results := writeRequest.Execute(testutils.TestContext(t)) diff --git a/plc4go/internal/umas/Browser.go b/plc4go/internal/umas/Browser.go index d19937d1364..73cfb2716a4 100644 --- a/plc4go/internal/umas/Browser.go +++ b/plc4go/internal/umas/Browser.go @@ -159,10 +159,10 @@ func (c *Connection) buildStructChildren(dataTypeId uint16, depth int) map[strin // buildArrayInfo turns the dimensions of an array type into what plc4x calls array info. // -// Note that the plc4go DefaultArrayInfo reports GetSize as upperBound - lowerBound, where plc4j's -// reports upperBound - lowerBound + 1 for the same bounds. The bounds themselves are carried over -// unchanged - they are what the dictionary says - so a consumer reading the bounds sees the same -// thing in both languages. +// The bounds are what the dictionary says, and both are inclusive, so GetSize counts them the +// same way plc4j does. They are marked as ranges because the device declared them as arrays - +// that is what tells a consumer to expect a list rather than a value - and the declared start +// index is also the base, so an address written with the PLC's own indices lines up with it. func buildArrayInfo(dimensions []readWriteModel.UmasArrayDimension) []apiModel.ArrayInfo { if len(dimensions) == 0 { return nil @@ -172,6 +172,8 @@ func buildArrayInfo(dimensions []readWriteModel.UmasArrayDimension) []apiModel.A arrayInfo = append(arrayInfo, &spiModel.DefaultArrayInfo{ LowerBound: dimension.GetStartIndex(), UpperBound: dimension.GetUpperBound(), + Base: dimension.GetStartIndex(), + Range: true, }) } return arrayInfo diff --git a/plc4go/pkg/api/model/mocks_test.go b/plc4go/pkg/api/model/mocks_test.go index 35aa25d2430..4476cdf14c5 100644 --- a/plc4go/pkg/api/model/mocks_test.go +++ b/plc4go/pkg/api/model/mocks_test.go @@ -59,6 +59,50 @@ func (_m *MockArrayInfo) EXPECT() *MockArrayInfo_Expecter { return &MockArrayInfo_Expecter{mock: &_m.Mock} } +// GetBase provides a mock function for the type MockArrayInfo +func (_mock *MockArrayInfo) GetBase() uint32 { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetBase") + } + + var r0 uint32 + if returnFunc, ok := ret.Get(0).(func() uint32); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(uint32) + } + return r0 +} + +// MockArrayInfo_GetBase_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBase' +type MockArrayInfo_GetBase_Call struct { + *mock.Call +} + +// GetBase is a helper method to define mock.On call +func (_e *MockArrayInfo_Expecter) GetBase() *MockArrayInfo_GetBase_Call { + return &MockArrayInfo_GetBase_Call{Call: _e.mock.On("GetBase")} +} + +func (_c *MockArrayInfo_GetBase_Call) Run(run func()) *MockArrayInfo_GetBase_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockArrayInfo_GetBase_Call) Return(v uint32) *MockArrayInfo_GetBase_Call { + _c.Call.Return(v) + return _c +} + +func (_c *MockArrayInfo_GetBase_Call) RunAndReturn(run func() uint32) *MockArrayInfo_GetBase_Call { + _c.Call.Return(run) + return _c +} + // GetLowerBound provides a mock function for the type MockArrayInfo func (_mock *MockArrayInfo) GetLowerBound() uint32 { ret := _mock.Called() @@ -191,6 +235,50 @@ func (_c *MockArrayInfo_GetUpperBound_Call) RunAndReturn(run func() uint32) *Moc return _c } +// IsRange provides a mock function for the type MockArrayInfo +func (_mock *MockArrayInfo) IsRange() bool { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for IsRange") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockArrayInfo_IsRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsRange' +type MockArrayInfo_IsRange_Call struct { + *mock.Call +} + +// IsRange is a helper method to define mock.On call +func (_e *MockArrayInfo_Expecter) IsRange() *MockArrayInfo_IsRange_Call { + return &MockArrayInfo_IsRange_Call{Call: _e.mock.On("IsRange")} +} + +func (_c *MockArrayInfo_IsRange_Call) Run(run func()) *MockArrayInfo_IsRange_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockArrayInfo_IsRange_Call) Return(b bool) *MockArrayInfo_IsRange_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockArrayInfo_IsRange_Call) RunAndReturn(run func() bool) *MockArrayInfo_IsRange_Call { + _c.Call.Return(run) + return _c +} + // String provides a mock function for the type MockArrayInfo func (_mock *MockArrayInfo) String() string { ret := _mock.Called() diff --git a/plc4go/pkg/api/model/plc_array_info.go b/plc4go/pkg/api/model/plc_array_info.go index be74ceaf0a1..ffdcc9ee38a 100644 --- a/plc4go/pkg/api/model/plc_array_info.go +++ b/plc4go/pkg/api/model/plc_array_info.go @@ -23,7 +23,20 @@ import "fmt" type ArrayInfo interface { fmt.Stringer + // GetSize is the number of elements. Both bounds are inclusive, so [0..7] is eight. GetSize() uint32 + // GetLowerBound is the lower index as the address wrote it, not the resolved offset. GetLowerBound() uint32 + // GetUpperBound is the upper index as the address wrote it. Inclusive. GetUpperBound() uint32 + // GetBase is the array's declared lower bound, as in PLCs not every array starts at 0. An + // address may state it - [4..7;1] selects elements 4 to 7 of an array declared from 1 - so + // that the bounds above can be written the way the PLC program declares them. The offset of + // an element from the start of the array is its index minus this value. Defaults to 0. + GetBase() uint32 + // IsRange reports whether the address wrote this dimension as a range rather than a single + // index. The two mean different things to a caller: a single index selects one element and + // yields a scalar, while a range yields an array - even a range spanning one element. Equal + // bounds alone cannot tell them apart, so the written form has to be remembered. + IsRange() bool } diff --git a/plc4go/pom.xml b/plc4go/pom.xml index 212524c8148..6d4ab1d514f 100644 --- a/plc4go/pom.xml +++ b/plc4go/pom.xml @@ -519,6 +519,22 @@ provided + + org.apache.plc4x + plc4x-protocols-slmp + 1.0.0-SNAPSHOT + test-jar + + provided + + + org.apache.plc4x + plc4x-protocols-iec-60870 + 1.0.0-SNAPSHOT + test-jar + + provided + org.apache.plc4x plc4x-protocols-simulated diff --git a/plc4go/spi/model/AddressConstraints.go b/plc4go/spi/model/AddressConstraints.go new file mode 100644 index 00000000000..f8c83ca2e5a --- /dev/null +++ b/plc4go/spi/model/AddressConstraints.go @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package model + +import "math" + +// AddressConstraints is what a protocol can actually encode of an array selection. The notation +// is the same for every driver, but the wire formats are not: an EtherNet/IP array index travels +// in a CIP MemberID whose instance field is a uint 8, and a driver addressing linear memory has +// no second dimension to express. A driver states its limits here, and a selection that exceeds +// them is reported when the address is parsed rather than truncated when it is serialized. +// +// This mirrors the plc4j type of the same name; the two bindings share a specification, not code. +type AddressConstraints struct { + // MaxIndex is the largest start offset the protocol can encode. It bounds where a selection + // begins, not where it ends: a protocol carrying a start index and an element count can read + // past this bound, it just cannot start past it. + MaxIndex uint32 + // MaxDimensions is how many dimensions the wire format carries. + MaxDimensions int + // OnlyTrailingDimensionMayBeRange reports whether every dimension but the last must be a + // single element - true where one request carries a single element count for the whole + // address, so that a range anywhere else would not be one contiguous read. + OnlyTrailingDimensionMayBeRange bool +} + +// Unconstrained imposes no limits beyond the grammar itself. +var Unconstrained = AddressConstraints{ + MaxIndex: math.MaxUint32, + MaxDimensions: math.MaxInt, +} + +// SingleDimension is a protocol addressing linear memory: any index, but only one dimension. +var SingleDimension = AddressConstraints{ + MaxIndex: math.MaxUint32, + MaxDimensions: 1, +} + +// WithMaxIndex returns a copy bounded to the given largest start offset. +func (c AddressConstraints) WithMaxIndex(maxIndex uint32) AddressConstraints { + c.MaxIndex = maxIndex + return c +} + +// WithMaxDimensions returns a copy carrying at most the given number of dimensions. +func (c AddressConstraints) WithMaxDimensions(maxDimensions int) AddressConstraints { + c.MaxDimensions = maxDimensions + return c +} + +// WithOnlyTrailingDimensionMayBeRange returns a copy in which only the last dimension may span +// more than one element. +func (c AddressConstraints) WithOnlyTrailingDimensionMayBeRange(onlyTrailing bool) AddressConstraints { + c.OnlyTrailingDimensionMayBeRange = onlyTrailing + return c +} diff --git a/plc4go/spi/model/ArrayNotationParser.go b/plc4go/spi/model/ArrayNotationParser.go new file mode 100644 index 00000000000..97bf5f844fe --- /dev/null +++ b/plc4go/spi/model/ArrayNotationParser.go @@ -0,0 +1,286 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package model + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" + + apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" +) + +// The array notation shared by every PLC4X tag address, in both language bindings. +// +// array-expression = group , { group } ; +// group = "[" , dimension , { "," , dimension } , "]" ; +// dimension = bounds , [ ";" , base ] ; +// bounds = index , [ ".." , index ] ; +// +// A range is inclusive of both bounds, so [0..7] is eight elements. A bare index is one element, +// so [4] is the fifth. ";base" states the array's declared lower bound - [4..7;1] selects +// elements 4 to 7 of an array declared from 1, which sit at offsets 3 to 6 - and defaults to 0. +// Each bracket group is one dimension, in written order, and several dimensions may be written +// comma-separated inside one bracket, which is the spelling Allen-Bradley and others use. +// +// This file is the single definition of that grammar in plc4go. The specification is shared with +// plc4j - see specs/002-unified-array-notation - but the code is not: any difference in what is +// accepted, or in what it means, is a defect rather than a Go dialect. +// +// Patterns are compiled once: address parsing runs per tag per request. + +const ( + // dimensionPattern is one dimension: an index or an inclusive range, with an optional + // declared lower bound. + dimensionPattern = `\d+(?:\.\.\d+)?(?:;\d+)?` + + // ArrayExpressionPattern is the array expression as a regex fragment with no capturing + // groups. A driver embeds this in its own address pattern so that the grammar has one + // definition rather than a copy per driver - see ArrayGroupPattern. + ArrayExpressionPattern = `(?:\[` + dimensionPattern + `(?:,` + dimensionPattern + `)*])+` + + // ArrayGroupPattern is the array expression as an optional named group, ready to splice into + // a driver's address pattern between the address and the type. The group is named "array". + ArrayGroupPattern = `(?P` + ArrayExpressionPattern + `)?` +) + +var ( + // expressionPattern matches a trailing run of strictly numeric bracket groups. + expressionPattern = regexp.MustCompile(ArrayExpressionPattern + `$`) + + // wholeExpressionPattern matches an expression in its entirety. + wholeExpressionPattern = regexp.MustCompile(`^` + ArrayExpressionPattern + `$`) + + // groupPattern matches one bracket group, whose content is one or more comma-separated + // dimensions. + groupPattern = regexp.MustCompile(`\[([^\]]*)]`) + + // singleDimensionPattern matches one dimension of a group. + singleDimensionPattern = regexp.MustCompile(`^(\d+)(?:\.\.(\d+))?(?:;(\d+))?$`) + + // legacyAfterType matches an address in the pre-migration shape "address:TYPE[n]", where the + // selection came after the type and meant a count. + legacyAfterType = regexp.MustCompile(`^(.+?):([A-Za-z_][A-Za-z_0-9]*(?:\(\d+\))?)\[(\d+)]$`) + + // legacyCountSuffix matches the pre-migration shape "address:TYPE:n", where a count trailed. + legacyCountSuffix = regexp.MustCompile(`^(.+?):([A-Za-z_][A-Za-z_0-9]*):(\d+)$`) +) + +// AddressPart is the part of an address before any trailing array expression. An address with no +// such expression is returned unchanged - including one whose brackets are not numeric, such as +// an OPC UA string identifier that happens to contain them. +func AddressPart(address string) string { + if loc := expressionPattern.FindStringIndex(address); loc != nil { + return address[:loc[0]] + } + return address +} + +// ExpressionPart is the trailing array expression of an address, or the empty string if it has +// none. +func ExpressionPart(address string) string { + if match := expressionPattern.FindString(address); match != "" { + return match + } + return "" +} + +// SelectsSingleElement reports whether an expression selects a single element rather than an +// array, which decides what a tag reports from GetArrayInfo: a bare index yields a scalar, a +// range yields an array even when it spans one element. [1] is a scalar and [1..1] is an array +// of one, so equal bounds alone cannot tell them apart - only the written form can. +// +// An expression is a single element when every one of its dimensions is a bare index. +func SelectsSingleElement(expression string) bool { + return expression != "" && !strings.Contains(expression, "..") +} + +// ParseArrayExpression parses an array expression into one ArrayInfo per dimension, in written +// order. An empty expression selects nothing and yields no dimensions. +// +// The address is quoted back in any error so the caller can find it. +func ParseArrayExpression(expression string, address string, constraints AddressConstraints) ([]apiModel.ArrayInfo, error) { + if expression == "" { + return nil, nil + } + if !wholeExpressionPattern.MatchString(expression) { + return nil, fmt.Errorf("invalid array expression '%s' in tag '%s': expected [index], "+ + "[lo..hi] or either with a ';base', repeated once per dimension", expression, address) + } + + var dimensions []apiModel.ArrayInfo + for _, group := range groupPattern.FindAllStringSubmatch(expression, -1) { + // A group may hold several dimensions separated by commas - the spelling Allen-Bradley + // and others use. "[1..2,3..4]" and "[1..2][3..4]" are the same selection. + for _, part := range strings.Split(group[1], ",") { + dimension, err := parseDimension(part, address, constraints) + if err != nil { + return nil, err + } + dimensions = append(dimensions, dimension) + } + } + + if len(dimensions) > constraints.MaxDimensions { + return nil, fmt.Errorf("array expression '%s' in tag '%s' has %d dimensions, but this "+ + "protocol carries at most %d", expression, address, len(dimensions), constraints.MaxDimensions) + } + if constraints.OnlyTrailingDimensionMayBeRange { + for i := 0; i < len(dimensions)-1; i++ { + // What matters is how the dimension was written, not how wide it turned out to be: + // "[1..1]" is a range that happens to span one element, and letting it pass here would + // hand the driver a leading range it has no element count for. + if dimensions[i].IsRange() { + return nil, fmt.Errorf("array expression '%s' in tag '%s' writes dimension %d as "+ + "a range, but this protocol carries one element count for the whole address, "+ + "so only the last dimension may be a range", + expression, address, i+1) + } + } + } + return dimensions, nil +} + +func parseDimension(part string, address string, constraints AddressConstraints) (apiModel.ArrayInfo, error) { + match := singleDimensionPattern.FindStringSubmatch(part) + if match == nil { + return nil, fmt.Errorf("invalid array dimension '%s' in tag '%s'", part, address) + } + + lowerBound, err := parseIndex(match[1], part, address) + if err != nil { + return nil, err + } + isRange := match[2] != "" + upperBound := lowerBound + if isRange { + if upperBound, err = parseIndex(match[2], part, address); err != nil { + return nil, err + } + } + var base uint32 + if match[3] != "" { + if base, err = parseIndex(match[3], part, address); err != nil { + return nil, err + } + } + + if upperBound < lowerBound { + return nil, fmt.Errorf("invalid array range '%s' in tag '%s': the upper bound %d is "+ + "below the lower bound %d", part, address, upperBound, lowerBound) + } + // The inclusive size is computed in a uint32, so a range spanning more than that would wrap - + // [0..4294967295] would report zero elements from a selection the syntax accepted. + if (uint64(upperBound) - uint64(lowerBound) + 1) > math.MaxUint32 { + return nil, fmt.Errorf("invalid array range '%s' in tag '%s': it spans %d elements, more than can be counted", + part, address, uint64(upperBound)-uint64(lowerBound)+1) + } + if lowerBound < base { + return nil, fmt.Errorf("invalid array range '%s' in tag '%s': index %d lies below the "+ + "declared lower bound %d", part, address, lowerBound, base) + } + // The bound applies to the offset the protocol actually encodes - the start of the selection + // - not to its last element. A CIP request carries a start index and an element count, so + // [0..300] is encodable where [300] is not. + if lowerBound-base > constraints.MaxIndex { + return nil, fmt.Errorf("invalid array range '%s' in tag '%s': index %d is out of range "+ + "0 to %d for this protocol", part, address, lowerBound-base, constraints.MaxIndex) + } + + return &DefaultArrayInfo{ + LowerBound: lowerBound, + UpperBound: upperBound, + Base: base, + Range: isRange, + }, nil +} + +func parseIndex(value string, part string, address string) (uint32, error) { + parsed, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid array range '%s' in tag '%s': '%s' is not a number this "+ + "protocol can address", part, address, value) + } + return uint32(parsed), nil +} + +// RenderArrayExpression renders dimensions back to their canonical form: one bracket per +// dimension, omitting what is defaulted - a base of 0 is dropped, and a bare index stays bare. +// The comma-separated spelling is accepted on input but never produced, so [1..2,3..4] renders as +// [1..2][3..4]. A one-element range still renders as a range: [8..8] is an array of one and [8] +// is a scalar, so collapsing it would change what the address means. +func RenderArrayExpression(dimensions []apiModel.ArrayInfo) string { + if len(dimensions) == 0 { + return "" + } + var sb strings.Builder + for _, dimension := range dimensions { + sb.WriteString("[") + sb.WriteString(strconv.FormatUint(uint64(dimension.GetLowerBound()), 10)) + if dimension.IsRange() { + sb.WriteString("..") + sb.WriteString(strconv.FormatUint(uint64(dimension.GetUpperBound()), 10)) + } + if dimension.GetBase() != 0 { + sb.WriteString(";") + sb.WriteString(strconv.FormatUint(uint64(dimension.GetBase()), 10)) + } + sb.WriteString("]") + } + return sb.String() +} + +// CurrentFormOf returns how to rewrite an address written before the array notation was unified, +// and whether one could be worked out. +// +// The brackets moved from after the type to before it, and a count became a range. An upgrading +// user who sees only "does not match pattern" has to work that out from a regex; this hands them +// the address they meant. +func CurrentFormOf(address string) (string, bool) { + if match := legacyAfterType.FindStringSubmatch(address); match != nil { + return match[1] + rangeFor(match[3]) + ":" + match[2], true + } + if match := legacyCountSuffix.FindStringSubmatch(address); match != nil { + return match[1] + rangeFor(match[3]) + ":" + match[2], true + } + return "", false +} + +func rangeFor(count string) string { + elements, err := strconv.Atoi(count) + if err != nil || elements <= 1 { + return "[0]" + } + return "[0.." + strconv.Itoa(elements-1) + "]" +} + +// InvalidAddressError reports an address the driver could not parse, naming the form it expected +// and - when the address looks like one written before the notation was unified - the address to +// write instead. +func InvalidAddressError(address string, expectedForm string) error { + message := fmt.Sprintf("invalid address '%s': expected %s", address, expectedForm) + if current, ok := CurrentFormOf(address); ok { + message += fmt.Sprintf(". The array notation moved before the type and a count became a "+ + "range, so this address is now written '%s'", current) + } + return fmt.Errorf("%s", message) +} diff --git a/plc4go/spi/model/ArrayNotationParser_test.go b/plc4go/spi/model/ArrayNotationParser_test.go new file mode 100644 index 00000000000..5e03f60b612 --- /dev/null +++ b/plc4go/spi/model/ArrayNotationParser_test.go @@ -0,0 +1,377 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The one definition of the array notation in plc4go, per the grammar contract shared with +// plc4j (specs/002-unified-array-notation/contracts/array-notation-grammar.md). +// +// The cases below are the plc4j suite's cases, deliberately the same ones rather than merely +// similar: the two bindings share no code, so the only evidence that an address means the same +// thing in both is that both satisfy the same specification against the same inputs. + +// --- the semantics table --- + +func TestArrayNotationParser_SingleDimensionResolvesToTheDocumentedOffsets(t *testing.T) { + for _, tt := range []struct { + expression string + size uint32 + first uint32 + last uint32 + }{ + {"[4]", 1, 4, 4}, + {"[0..7]", 8, 0, 7}, + {"[4;1]", 1, 3, 3}, + {"[4..7;1]", 4, 3, 6}, + {"[0]", 1, 0, 0}, + {"[7..7]", 1, 7, 7}, + } { + t.Run(tt.expression, func(t *testing.T) { + dimensions, err := ParseArrayExpression(tt.expression, "tag"+tt.expression, Unconstrained) + require.NoError(t, err) + require.Len(t, dimensions, 1) + + only := dimensions[0] + assert.Equal(t, tt.size, only.GetSize(), "size") + assert.Equal(t, tt.first, only.GetLowerBound()-only.GetBase(), "first offset") + assert.Equal(t, tt.last, only.GetUpperBound()-only.GetBase(), "last offset") + }) + } +} + +// A bare index and a one-element range cover the same element but are not the same selection: +// the first yields a scalar and the second an array of one. +func TestArrayNotationParser_ABareIndexIsNotTheSameAsAOneElementRange(t *testing.T) { + index, err := ParseArrayExpression("[4]", "tag[4]", Unconstrained) + require.NoError(t, err) + arrayRange, err := ParseArrayExpression("[4..4]", "tag[4..4]", Unconstrained) + require.NoError(t, err) + + assert.NotEqual(t, arrayRange, index) + assert.False(t, index[0].IsRange()) + assert.True(t, arrayRange[0].IsRange()) + assert.Equal(t, index[0].GetLowerBound(), arrayRange[0].GetLowerBound()) + assert.Equal(t, uint32(1), index[0].GetSize()) + assert.Equal(t, uint32(1), arrayRange[0].GetSize()) +} + +func TestArrayNotationParser_WrittenBoundsArePreservedNotResolved(t *testing.T) { + dimensions, err := ParseArrayExpression("[4..7;1]", "tag[4..7;1]", Unconstrained) + require.NoError(t, err) + + assert.Equal(t, uint32(4), dimensions[0].GetLowerBound(), "lower bound is as written") + assert.Equal(t, uint32(7), dimensions[0].GetUpperBound(), "upper bound is as written") + assert.Equal(t, uint32(1), dimensions[0].GetBase(), "declared base") + assert.Equal(t, uint32(4), dimensions[0].GetSize()) +} + +func TestArrayNotationParser_BaseDefaultsToZero(t *testing.T) { + dimensions, err := ParseArrayExpression("[4]", "tag[4]", Unconstrained) + require.NoError(t, err) + assert.Equal(t, uint32(0), dimensions[0].GetBase()) +} + +func TestArrayNotationParser_MultipleDimensionsKeepTheirWrittenOrder(t *testing.T) { + dimensions, err := ParseArrayExpression("[1..2][0..5]", "tag[1..2][0..5]", Unconstrained) + require.NoError(t, err) + + require.Len(t, dimensions, 2) + assert.Equal(t, uint32(1), dimensions[0].GetLowerBound()) + assert.Equal(t, uint32(2), dimensions[0].GetUpperBound()) + assert.Equal(t, uint32(0), dimensions[1].GetLowerBound()) + assert.Equal(t, uint32(5), dimensions[1].GetUpperBound()) +} + +func TestArrayNotationParser_EachDimensionCarriesItsOwnBase(t *testing.T) { + dimensions, err := ParseArrayExpression("[4..7;1][7..10;2]", "tag[4..7;1][7..10;2]", Unconstrained) + require.NoError(t, err) + + require.Len(t, dimensions, 2) + assert.Equal(t, uint32(3), dimensions[0].GetLowerBound()-dimensions[0].GetBase()) + assert.Equal(t, uint32(6), dimensions[0].GetUpperBound()-dimensions[0].GetBase()) + assert.Equal(t, uint32(5), dimensions[1].GetLowerBound()-dimensions[1].GetBase()) + assert.Equal(t, uint32(8), dimensions[1].GetUpperBound()-dimensions[1].GetBase()) +} + +// --- the rejection table --- + +func TestArrayNotationParser_MalformedExpressionsAreRejected(t *testing.T) { + for _, expression := range []string{ + "[]", // no index + "[7..4]", // upper below lower + "[0;1]", // resolved offset is negative + "[-1]", // negative component + "[1..-2]", // negative component + "[a]", // non-numeric + "[1..x]", // non-numeric + "[1..2;]", // empty base + "[1..]", // missing upper bound + "[..2]", // missing lower bound + "[0,]", // trailing comma + "[,1]", // leading comma + "[0,,1]", // empty dimension + "[0, 1]", // space in the list + } { + t.Run(expression, func(t *testing.T) { + _, err := ParseArrayExpression(expression, "tag"+expression, Unconstrained) + require.Error(t, err) + assert.Contains(t, err.Error(), "tag"+expression, "the message must name the address") + }) + } +} + +// --- driver constraints --- + +func TestArrayNotationParser_IndexBeyondTheProtocolMaximumIsRejected(t *testing.T) { + eip := SingleDimension.WithMaxIndex(255) + + _, err := ParseArrayExpression("[255]", "tag[255]", eip) + require.NoError(t, err) + // The bound is on where the selection starts, not where it ends: a CIP request carries a + // start index and an element count, so a long run from an encodable start is fine. + _, err = ParseArrayExpression("[0..300]", "tag[0..300]", eip) + require.NoError(t, err) + _, err = ParseArrayExpression("[256;1]", "tag[256;1]", eip) + require.NoError(t, err) + + _, err = ParseArrayExpression("[256]", "tag[256]", eip) + require.Error(t, err) + assert.Contains(t, err.Error(), "255", "the message must name the real bound") +} + +func TestArrayNotationParser_MoreDimensionsThanTheProtocolCarriesIsRejected(t *testing.T) { + _, err := ParseArrayExpression("[1][2]", "tag[1][2]", SingleDimension) + require.Error(t, err) + assert.Contains(t, err.Error(), "1") +} + +func TestArrayNotationParser_InteriorRangeIsRejectedWhereOnlyTheTrailingDimensionMaySpan(t *testing.T) { + trailingOnly := Unconstrained.WithOnlyTrailingDimensionMayBeRange(true) + + _, err := ParseArrayExpression("[1][0..3]", "tag[1][0..3]", trailingOnly) + require.NoError(t, err) + + _, err = ParseArrayExpression("[0..3][1]", "tag[0..3][1]", trailingOnly) + require.Error(t, err) + + // A one-element range is still a range. Judging this by the span let "[1..1][2]" through, + // handing the driver a leading range it has no element count for. + _, err = ParseArrayExpression("[1..1][2]", "tag[1..1][2]", trailingOnly) + require.Error(t, err) + + // A single index in the same position stays legal, which is what the constraint is for. + _, err = ParseArrayExpression("[1][2]", "tag[1][2]", trailingOnly) + require.NoError(t, err) +} + +// --- splitting an address --- + +func TestArrayNotationParser_TrailingExpressionIsSplitFromTheAddress(t *testing.T) { + for _, tt := range []struct{ input, address, expression string }{ + {"myTag[0..7]", "myTag", "[0..7]"}, + {"myTag", "myTag", ""}, + {"a.b[2]", "a.b", "[2]"}, + {"40001[0..3]", "40001", "[0..3]"}, + {"t[1..2][0..5]", "t", "[1..2][0..5]"}, + } { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.address, AddressPart(tt.input)) + assert.Equal(t, tt.expression, ExpressionPart(tt.input)) + }) + } +} + +// Only a strictly numeric trailing run counts. An identifier that happens to contain brackets is +// left on the address. +func TestArrayNotationParser_NonNumericBracketsAreNotAnArrayExpression(t *testing.T) { + assert.Equal(t, "Some[Node]Name", AddressPart("Some[Node]Name")) + assert.Equal(t, "", ExpressionPart("Some[Node]Name")) +} + +// --- rendering back --- + +func TestArrayNotationParser_RenderingReproducesTheCanonicalForm(t *testing.T) { + for _, expression := range []string{ + "[4]", "[0..7]", "[4;1]", "[4..7;1]", "[1..2][0..5]", "[4..7;1][7..10;2]", + } { + t.Run(expression, func(t *testing.T) { + dimensions, err := ParseArrayExpression(expression, "tag", Unconstrained) + require.NoError(t, err) + assert.Equal(t, expression, RenderArrayExpression(dimensions)) + }) + } +} + +// Canonical form omits what is defaulted - a base of 0 - but never the range form, because +// dropping that would turn an array of one into a scalar. +func TestArrayNotationParser_CanonicalFormOmitsDefaultsButKeepsTheRangeForm(t *testing.T) { + for _, tt := range []struct{ written, canonical string }{ + {"[4..4;0]", "[4..4]"}, + {"[4;0]", "[4]"}, + {"[0..7;0]", "[0..7]"}, + } { + t.Run(tt.written, func(t *testing.T) { + dimensions, err := ParseArrayExpression(tt.written, "tag", Unconstrained) + require.NoError(t, err) + assert.Equal(t, tt.canonical, RenderArrayExpression(dimensions)) + }) + } +} + +func TestArrayNotationParser_AnAbsentExpressionRendersAsNothing(t *testing.T) { + assert.Equal(t, "", RenderArrayExpression(nil)) + dimensions, err := ParseArrayExpression("", "tag", Unconstrained) + require.NoError(t, err) + assert.Empty(t, dimensions) +} + +// --- the comma spelling --- + +// Allen-Bradley and others write the dimensions of one array inside a single bracket. It is the +// same selection, so it parses the same - and renders back in the one canonical form. +func TestArrayNotationParser_TheCommaSpellingIsTheSameSelection(t *testing.T) { + for _, tt := range []struct{ comma, brackets string }{ + {"[0,1]", "[0][1]"}, + {"[1..2,3..4]", "[1..2][3..4]"}, + {"[1..2;1,3..4;1]", "[1..2;1][3..4;1]"}, + {"[0,1,2]", "[0][1][2]"}, + {"[1..2,3]", "[1..2][3]"}, + } { + t.Run(tt.comma, func(t *testing.T) { + viaComma, err := ParseArrayExpression(tt.comma, "tag"+tt.comma, Unconstrained) + require.NoError(t, err) + viaBrackets, err := ParseArrayExpression(tt.brackets, "tag"+tt.brackets, Unconstrained) + require.NoError(t, err) + assert.Equal(t, viaBrackets, viaComma) + }) + } +} + +func TestArrayNotationParser_RenderingAlwaysProducesOneBracketPerDimension(t *testing.T) { + for _, tt := range []struct{ written, canonical string }{ + {"[0,1]", "[0][1]"}, + {"[1..2,3..4]", "[1..2][3..4]"}, + {"[0][1]", "[0][1]"}, + {"[0,1][2]", "[0][1][2]"}, + } { + t.Run(tt.written, func(t *testing.T) { + dimensions, err := ParseArrayExpression(tt.written, "tag", Unconstrained) + require.NoError(t, err) + assert.Equal(t, tt.canonical, RenderArrayExpression(dimensions)) + }) + } +} + +func TestArrayNotationParser_TheCommaSpellingIsSplitFromTheAddress(t *testing.T) { + assert.Equal(t, "myTag", AddressPart("myTag[1..2,3..4]")) + assert.Equal(t, "[1..2,3..4]", ExpressionPart("myTag[1..2,3..4]")) +} + +// --- what the caller receives --- + +// GetArrayInfo describes the value the caller gets, so a consumer can decide from it alone +// whether to render a scalar or a list. A bare index is a scalar; a range is an array even when +// it spans a single element. +func TestArrayNotationParser_ABareIndexSelectsAScalarButARangeDoesNot(t *testing.T) { + for _, tt := range []struct { + expression string + scalar bool + }{ + {"[1]", true}, + {"[4]", true}, + {"[4;1]", true}, + {"[1][2]", true}, + {"[1..1]", false}, + {"[0..7]", false}, + {"[4..7;1]", false}, + {"[1][0..5]", false}, + {"", false}, + } { + t.Run(tt.expression, func(t *testing.T) { + assert.Equal(t, tt.scalar, SelectsSingleElement(tt.expression)) + }) + } +} + +// --- guidance for addresses written before the migration --- + +func TestArrayNotationParser_AnAddressWrittenBeforeTheMigrationIsRewritten(t *testing.T) { + for _, tt := range []struct{ legacy, current string }{ + {"holding-register:1:INT[4]", "holding-register:1[0..3]:INT"}, + {"%DB42:28.0:BYTE[8]", "%DB42:28.0[0..7]:BYTE"}, + {"%DB1:0:STRING(40)[3]", "%DB1:0[0..2]:STRING(40)"}, + {"D100:WORD[2]", "D100[0..1]:WORD"}, + {"0x4020/0:DINT[4]", "0x4020/0[0..3]:DINT"}, + {"myTag:DINT:8", "myTag[0..7]:DINT"}, + {"foo:INT[1]", "foo[0]:INT"}, + } { + t.Run(tt.legacy, func(t *testing.T) { + current, ok := CurrentFormOf(tt.legacy) + require.True(t, ok, tt.legacy) + assert.Equal(t, tt.current, current) + }) + } +} + +func TestArrayNotationParser_AnAddressThatIsNotInTheOldShapeGetsNoRewrite(t *testing.T) { + for _, address := range []string{"myTag", "myTag[0..3]:DINT", "holding-register:1[0..3]:INT", "nonsense"} { + t.Run(address, func(t *testing.T) { + _, ok := CurrentFormOf(address) + assert.False(t, ok, address) + }) + } +} + +// --- round trip --- + +func TestArrayNotationParser_ASelectionSurvivesBeingRenderedAndParsedAgain(t *testing.T) { + for _, written := range []string{ + "[4]", "[0..7]", "[4;1]", "[4..7;1]", "[0]", "[7..7]", + "[1..2][0..5]", "[4..7;1][7..10;2]", "[0][1][2]", + "[0,1]", "[1..2,3..4]", "[0,1][2]", "[1..2;1,3..4;1]", + } { + t.Run(written, func(t *testing.T) { + parsed, err := ParseArrayExpression(written, "tag"+written, Unconstrained) + require.NoError(t, err) + reparsed, err := ParseArrayExpression(RenderArrayExpression(parsed), "tag", Unconstrained) + require.NoError(t, err) + assert.Equal(t, parsed, reparsed) + }) + } +} + +// A range the syntax accepts but no count can hold must be refused at the parse, not silently +// wrapped: [0..4294967295] spans 2^32 elements, which is zero in a uint32. +func TestParseArrayExpression_refusesARangeThatCannotBeCounted(t *testing.T) { + _, err := ParseArrayExpression("[0..4294967295]", "%test[0..4294967295]", Unconstrained) + require.Error(t, err) + assert.Contains(t, err.Error(), "more than can be counted") + + // One below the limit still parses, and counts what it says. + dimensions, err := ParseArrayExpression("[0..4294967294]", "%test[0..4294967294]", Unconstrained) + require.NoError(t, err) + assert.Equal(t, uint32(4294967295), dimensions[0].GetSize()) +} diff --git a/plc4go/spi/model/DefaultArrayInfo.go b/plc4go/spi/model/DefaultArrayInfo.go index 4e074f228ca..ba6c13066fc 100644 --- a/plc4go/spi/model/DefaultArrayInfo.go +++ b/plc4go/spi/model/DefaultArrayInfo.go @@ -27,10 +27,19 @@ var _ apiModel.ArrayInfo = &DefaultArrayInfo{} type DefaultArrayInfo struct { LowerBound uint32 UpperBound uint32 + // Base is the array's declared lower bound; 0 for an array that does not declare one. + Base uint32 + // Range records whether the address wrote this dimension as a range. A one-element range is + // still a range, so this cannot be derived from the bounds - see apiModel.ArrayInfo.IsRange. + Range bool } +// GetSize is the number of elements. Both bounds are inclusive, so {0, 7} is eight elements. +// This used to return UpperBound-LowerBound, treating the upper bound as exclusive, which +// disagreed with plc4j about the same address and with the drivers that build from an inclusive +// range. func (t *DefaultArrayInfo) GetSize() uint32 { - return t.UpperBound - t.LowerBound + return t.UpperBound - t.LowerBound + 1 } func (t *DefaultArrayInfo) GetLowerBound() uint32 { @@ -40,3 +49,11 @@ func (t *DefaultArrayInfo) GetLowerBound() uint32 { func (t *DefaultArrayInfo) GetUpperBound() uint32 { return t.UpperBound } + +func (t *DefaultArrayInfo) GetBase() uint32 { + return t.Base +} + +func (t *DefaultArrayInfo) IsRange() bool { + return t.Range +} diff --git a/plc4go/spi/model/DefaultArrayInfo_plc4xgen.go b/plc4go/spi/model/DefaultArrayInfo_plc4xgen.go index 3210bddcc34..cfcd0651acd 100644 --- a/plc4go/spi/model/DefaultArrayInfo_plc4xgen.go +++ b/plc4go/spi/model/DefaultArrayInfo_plc4xgen.go @@ -56,6 +56,14 @@ func (d *DefaultArrayInfo) SerializeWithWriteBuffer(ctx context.Context, writeBu if err := writeBuffer.WriteUint32("upperBound", 32, d.UpperBound); err != nil { return err } + + if err := writeBuffer.WriteUint32("base", 32, d.Base); err != nil { + return err + } + + if err := writeBuffer.WriteBit("range", d.Range); err != nil { + return err + } if err := writeBuffer.PopContext("ArrayInfo"); err != nil { return err } diff --git a/plc4go/spi/values/mocks_test.go b/plc4go/spi/values/mocks_test.go index 4718687783a..5748792c0a5 100644 --- a/plc4go/spi/values/mocks_test.go +++ b/plc4go/spi/values/mocks_test.go @@ -1966,6 +1966,50 @@ func (_m *MockArrayInfo) EXPECT() *MockArrayInfo_Expecter { return &MockArrayInfo_Expecter{mock: &_m.Mock} } +// GetBase provides a mock function for the type MockArrayInfo +func (_mock *MockArrayInfo) GetBase() uint32 { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetBase") + } + + var r0 uint32 + if returnFunc, ok := ret.Get(0).(func() uint32); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(uint32) + } + return r0 +} + +// MockArrayInfo_GetBase_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBase' +type MockArrayInfo_GetBase_Call struct { + *mock.Call +} + +// GetBase is a helper method to define mock.On call +func (_e *MockArrayInfo_Expecter) GetBase() *MockArrayInfo_GetBase_Call { + return &MockArrayInfo_GetBase_Call{Call: _e.mock.On("GetBase")} +} + +func (_c *MockArrayInfo_GetBase_Call) Run(run func()) *MockArrayInfo_GetBase_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockArrayInfo_GetBase_Call) Return(v uint32) *MockArrayInfo_GetBase_Call { + _c.Call.Return(v) + return _c +} + +func (_c *MockArrayInfo_GetBase_Call) RunAndReturn(run func() uint32) *MockArrayInfo_GetBase_Call { + _c.Call.Return(run) + return _c +} + // GetLowerBound provides a mock function for the type MockArrayInfo func (_mock *MockArrayInfo) GetLowerBound() uint32 { ret := _mock.Called() @@ -2098,6 +2142,50 @@ func (_c *MockArrayInfo_GetUpperBound_Call) RunAndReturn(run func() uint32) *Moc return _c } +// IsRange provides a mock function for the type MockArrayInfo +func (_mock *MockArrayInfo) IsRange() bool { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for IsRange") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockArrayInfo_IsRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsRange' +type MockArrayInfo_IsRange_Call struct { + *mock.Call +} + +// IsRange is a helper method to define mock.On call +func (_e *MockArrayInfo_Expecter) IsRange() *MockArrayInfo_IsRange_Call { + return &MockArrayInfo_IsRange_Call{Call: _e.mock.On("IsRange")} +} + +func (_c *MockArrayInfo_IsRange_Call) Run(run func()) *MockArrayInfo_IsRange_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockArrayInfo_IsRange_Call) Return(b bool) *MockArrayInfo_IsRange_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockArrayInfo_IsRange_Call) RunAndReturn(run func() bool) *MockArrayInfo_IsRange_Call { + _c.Call.Return(run) + return _c +} + // String provides a mock function for the type MockArrayInfo func (_mock *MockArrayInfo) String() string { ret := _mock.Called() diff --git a/plc4go/tests/arraynotation/legacy_addresses_test.go b/plc4go/tests/arraynotation/legacy_addresses_test.go new file mode 100644 index 00000000000..cc5aa297166 --- /dev/null +++ b/plc4go/tests/arraynotation/legacy_addresses_test.go @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package arraynotation holds the cross-driver checks for the unified array notation. They live +// outside the driver packages because what they assert is a property of the whole binding: the +// release notes tell an upgrading user that every address whose meaning changed is either +// rejected with its replacement named, or listed as one of the two silent changes. That claim is +// only true if it holds for every driver at once, which is what these tests check. +package arraynotation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x/plc4go/internal/ads" + "github.com/apache/plc4x/plc4go/internal/eip" + "github.com/apache/plc4x/plc4go/internal/firmata" + "github.com/apache/plc4x/plc4go/internal/knxnetip" + "github.com/apache/plc4x/plc4go/internal/modbus" + "github.com/apache/plc4x/plc4go/internal/s7" + "github.com/apache/plc4x/plc4go/internal/simulated" + "github.com/apache/plc4x/plc4go/internal/slmp" + apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" +) + +type parseTag func(string) (apiModel.PlcTag, error) + +// Every pre-migration address in the release notes must be rejected, and the rejection must name +// the address to write instead. A rejection alone is not enough: the point of moving the +// brackets was that an upgrade reports the change rather than quietly returning different data, +// and a user with a configuration full of old addresses needs to be told what to write. +func TestEveryLegacyAddressIsRejectedWithItsReplacement(t *testing.T) { + for _, c := range []struct { + driver string + parse parseTag + address string + replacement string + }{ + {"s7", s7.NewTagHandler().ParseTag, "%M100:INT[10]", "%M100[0..9]:INT"}, + {"s7 string", s7.NewTagHandler().ParseTag, "%DB69.DBX68:WSTRING[3]", "%DB69.DBX68[0..2]:WSTRING"}, + {"modbus", modbus.NewTagHandler().ParseTag, "holding-register:1:INT[4]", "holding-register:1[0..3]:INT"}, + {"slmp", slmp.NewTagHandler().ParseTag, "D100:INT[4]", "D100[0..3]:INT"}, + {"eip", eip.NewTagHandler().ParseTag, "%rate:DINT:4", "%rate[0..3]:DINT"}, + {"simulated", simulated.NewTagHandler().ParseTag, "RANDOM/foo:INT[4]", "RANDOM/foo[0..3]:INT"}, + {"knxnetip memory", knxnetip.NewTagHandler().ParseTag, "1.2.3#4B1C:UINT[4]", "1.2.3#4B1C[0..3]:UINT"}, + {"ads direct", ads.NewTagHandler().ParseTag, "0x4020/0:DINT[4]", "0x4020/0[0..3]:DINT"}, + // The start-and-count form was a plc4go extension with no counterpart in plc4j. + {"ads start-and-count", ads.NewTagHandler().ParseTag, "MAIN.g_arr[2:4]", "MAIN.g_arr[2..5]"}, + } { + t.Run(c.driver, func(t *testing.T) { + _, err := c.parse(c.address) + require.Error(t, err, c.address) + assert.Contains(t, err.Error(), c.replacement, + "the rejection must name the address to write instead") + }) + } +} + +// The two addresses that parse before and after, and only change meaning. Neither can be +// rejected, so the release notes carry them - and these tests are what keeps that list honest. +func TestTheSilentChangesAreExactlyTheTwoThatAreDocumented(t *testing.T) { + // Firmata: [n] was a run of n pins and is now the pin at index n. + pin, err := firmata.NewTagHandler().ParseTag("digital:2[3]") + require.NoError(t, err) + assert.Equal(t, "digital:5", pin.GetAddressString(), "pin 5, not three pins from pin 2") + assert.Empty(t, pin.GetArrayInfo(), "one pin is a scalar") + + // ADS: [n] was a count of n elements and is now the element at index n. + element, err := ads.NewTagHandler().ParseTag("MAIN.g_arr[3]") + require.NoError(t, err) + assert.Equal(t, "MAIN.g_arr[3]", element.GetAddressString()) + assert.Empty(t, element.GetArrayInfo(), "one element is a scalar, not three elements") +} + +// The same address selects the same elements in plc4go as in plc4j. The two bindings share a +// specification rather than code, so this is asserted case by case; the numbers here are the +// ones the Java parity tests assert. +func TestOneAddressMeansOneThingAcrossDrivers(t *testing.T) { + for _, c := range []struct { + driver string + parse parseTag + address string + }{ + {"s7", s7.NewTagHandler().ParseTag, "%M100[0..7]:INT"}, + {"modbus", modbus.NewTagHandler().ParseTag, "holding-register:1[0..7]:INT"}, + {"slmp", slmp.NewTagHandler().ParseTag, "D100[0..7]:INT"}, + {"eip", eip.NewTagHandler().ParseTag, "%rate[0..7]:DINT"}, + {"simulated", simulated.NewTagHandler().ParseTag, "RANDOM/foo[0..7]:INT"}, + {"firmata", firmata.NewTagHandler().ParseTag, "digital:0[0..7]"}, + {"ads", ads.NewTagHandler().ParseTag, "MAIN.g_arr[0..7]"}, + } { + t.Run(c.driver, func(t *testing.T) { + tag, err := c.parse(c.address) + require.NoError(t, err) + + dimensions := tag.GetArrayInfo() + require.Len(t, dimensions, 1, "one dimension") + assert.Equal(t, uint32(8), dimensions[0].GetSize(), "eight elements") + assert.True(t, dimensions[0].IsRange(), "written as a range") + + reparsed, err := c.parse(tag.GetAddressString()) + require.NoError(t, err, "a rendered address must parse back") + assert.Equal(t, tag, reparsed) + }) + } +} diff --git a/plc4j/api/src/main/java/org/apache/plc4x/java/api/model/ArrayInfo.java b/plc4j/api/src/main/java/org/apache/plc4x/java/api/model/ArrayInfo.java index 9733aa791e3..324c63b8d5f 100644 --- a/plc4j/api/src/main/java/org/apache/plc4x/java/api/model/ArrayInfo.java +++ b/plc4j/api/src/main/java/org/apache/plc4x/java/api/model/ArrayInfo.java @@ -26,17 +26,45 @@ public interface ArrayInfo { int getSize(); /** - * As in PLCs not every array starts at 0, we need to be flexible with this. - * In the default usage scenario of a simple array [6] this index will be 0 by default. + * The lower index of the selection, as it was written in the address. For a single element + * such as [6] this is 6, and {@link #getUpperBound()} is 6 as well - a bare index selects + * one element, not a range starting at zero. * @return Returns the index of lower bound of the array. */ int getLowerBound(); /** - * As in PLCs not every array starts at 0, we need to be flexible with this. - * In the default usage scenario of a simple array [6] this index will be match the array size. + * The upper index of the selection, as it was written in the address. For the range [0..7] + * this is 7 and {@link #getSize()} is 8, both bounds being inclusive. * @return Returns the index of upper bound of the array. */ int getUpperBound(); + /** + * The array's declared lower bound, as in PLCs not every array starts at 0. An address may + * state it explicitly - [4..7;1] selects elements 4 to 7 of an array declared from 1 - so + * that the bounds above can be written the way the PLC program declares them. The offset of + * an element from the start of the array is its index minus this value. + * + *

Defaults to 0, which is correct for any array that does not declare otherwise. + * + * @return Returns the index the array is declared to start at. + */ + default int getBase() { + return 0; + } + + /** + * Whether the address wrote this dimension as a range rather than a single index. + * + *

The two mean different things to a caller: a single index selects one element and yields + * a scalar, while a range yields an array - even a range spanning one element. Equal bounds + * alone cannot tell them apart, so the written form has to be remembered. + * + * @return true when the dimension was written as a range. + */ + default boolean isRange() { + return getLowerBound() != getUpperBound(); + } + } diff --git a/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/AdsTcpConnection.java b/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/AdsTcpConnection.java index 56540bd7ad1..9b874c8ec62 100644 --- a/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/AdsTcpConnection.java +++ b/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/AdsTcpConnection.java @@ -24,6 +24,7 @@ import org.apache.plc4x.java.ads.discovery.readwrite.Constants; import org.apache.plc4x.java.ads.model.AdsSubscriptionHandle; import org.apache.plc4x.java.ads.readwrite.*; +import org.apache.plc4x.java.ads.readwrite.AdsDataTypeArrayInfo; import org.apache.plc4x.java.ads.resolution.ResolvedAdsTag; import org.apache.plc4x.java.ads.resolution.TagResolver; import org.apache.plc4x.java.ads.resolution.ValueDecoder; @@ -40,6 +41,7 @@ import org.apache.plc4x.java.api.exceptions.PlcRuntimeException; import org.apache.plc4x.java.api.messages.*; import org.apache.plc4x.java.api.model.*; +import org.apache.plc4x.java.api.model.ArrayInfo; import org.apache.plc4x.java.api.types.ConnectionStateChangeType; import org.apache.plc4x.java.api.types.PlcResponseCode; import org.apache.plc4x.java.api.types.PlcSubscriptionType; @@ -74,6 +76,7 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.*; +import java.util.ArrayList; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReentrantLock; @@ -510,14 +513,14 @@ private ResolvedAdsTag resolveForReadOrWrite(TagResolver resolver, PlcTag tag) { return switch (tag) { case null -> throw new PlcInvalidTagException("Tag could not be parsed"); case SymbolicAdsTag s -> resolver.resolve(s); - case DirectAdsStringTag s -> new ResolvedAdsTag(s.getIndexGroup(), s.getIndexOffset(), + case DirectAdsStringTag s -> TagResolver.withDirectSelection(new ResolvedAdsTag(s.getIndexGroup(), s.getIndexOffset(), computeDirectSize(s.getPlcDataType(), s.getStringLength(), s.getNumberOfElements()), s.getPlcDataType(), TagResolver.plcValueTypeForName(s.getPlcDataType(), null), - s.getStringLength(), Collections.emptyList()); - case DirectAdsTag d -> new ResolvedAdsTag(d.getIndexGroup(), d.getIndexOffset(), + s.getStringLength(), Collections.emptyList()), s.getArrayInfo()); + case DirectAdsTag d -> TagResolver.withDirectSelection(new ResolvedAdsTag(d.getIndexGroup(), d.getIndexOffset(), computeDirectSize(d.getPlcDataType(), 0, d.getNumberOfElements()), d.getPlcDataType(), TagResolver.plcValueTypeForName(d.getPlcDataType(), null), - 0, Collections.emptyList()); + 0, Collections.emptyList()), d.getArrayInfo()); default -> throw new PlcInvalidTagException("Unsupported tag type: " + tag.getClass().getName()); }; } @@ -1121,8 +1124,8 @@ protected CompletableFuture onBrowseWithInterceptor(PlcBrowse List arrayInfo = new ArrayList<>(dataType.getArrayInfo().size()); List itemArrayInfo = new ArrayList<>(dataType.getArrayInfo().size()); for (AdsDataTypeArrayInfo a : dataType.getArrayInfo()) { - arrayInfo.add(new DefaultArrayInfo((int) a.getLowerBound(), (int) a.getUpperBound())); - itemArrayInfo.add(new DefaultArrayInfo((int) a.getLowerBound(), (int) a.getUpperBound())); + arrayInfo.add(new DefaultArrayInfo((int) a.getLowerBound(), (int) a.getUpperBound(), (int) a.getLowerBound(), true)); + itemArrayInfo.add(new DefaultArrayInfo((int) a.getLowerBound(), (int) a.getUpperBound(), (int) a.getLowerBound(), true)); } DefaultPlcBrowseItem item = new DefaultPlcBrowseItem( new SymbolicAdsTag(symbol.getName(), plcValueType, arrayInfo), symbol.getName(), @@ -1178,8 +1181,8 @@ private List getBrowseItems(String basePath, long baseGroupId, lo List arrayInfo = new ArrayList<>(childDataType.getArrayInfo().size()); List itemArrayInfo = new ArrayList<>(childDataType.getArrayInfo().size()); for (AdsDataTypeArrayInfo a : childDataType.getArrayInfo()) { - arrayInfo.add(new DefaultArrayInfo((int) a.getLowerBound(), (int) a.getUpperBound())); - itemArrayInfo.add(new DefaultArrayInfo((int) a.getLowerBound(), (int) a.getUpperBound())); + arrayInfo.add(new DefaultArrayInfo((int) a.getLowerBound(), (int) a.getUpperBound(), (int) a.getLowerBound(), true)); + itemArrayInfo.add(new DefaultArrayInfo((int) a.getLowerBound(), (int) a.getUpperBound(), (int) a.getLowerBound(), true)); } values.add(new DefaultPlcBrowseItem( new SymbolicAdsTag(basePath + "." + child.getMainName(), plc4xPlcValueType, arrayInfo), diff --git a/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/resolution/TagResolver.java b/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/resolution/TagResolver.java index 2827d0a4b63..2039e146d0c 100644 --- a/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/resolution/TagResolver.java +++ b/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/resolution/TagResolver.java @@ -23,9 +23,12 @@ import org.apache.plc4x.java.ads.readwrite.AdsDataTypeTableEntry; import org.apache.plc4x.java.ads.readwrite.AdsSymbolTableEntry; import org.apache.plc4x.java.ads.tag.SymbolicAdsTag; +import org.apache.plc4x.java.api.model.ArrayInfo; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; import org.apache.plc4x.java.api.types.PlcValueType; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -70,7 +73,13 @@ public ResolvedAdsTag resolve(SymbolicAdsTag tag) { + " option or address the value directly as" + " '{IndexGroup}/{IndexOffset}:{TYPE}'."); } - AddressParser.AddressPart root = AddressParser.parse(tag.getSymbolicAddress()); + // The trailing selection is not part of the symbolic path; it says which elements of the + // resolved location to read. Its first index is appended to the path's own indices so the + // existing bounds checking and lower-bound arithmetic apply to it unchanged. + String path = ArrayNotationParser.addressPart(tag.getSymbolicAddress()); + List selection = tag.getSelection(); + AddressParser.AddressPart root = withSelectionStart(AddressParser.parse(path), selection); + AdsSymbolTableEntry symbol = symbolTable.get(root.baseSegment()); if (symbol == null) { throw new PlcInvalidTagException("Unknown symbol: " + root.baseSegment()); @@ -80,27 +89,205 @@ public ResolvedAdsTag resolve(SymbolicAdsTag tag) { throw new PlcInvalidTagException( "Unknown data type for symbol " + root.baseSegment() + ": " + symbol.getDataTypeName()); } - return resolvePart(symbol.getGroup(), symbol.getOffset(), dataType, - root.arrayIndices(), root.child()); + verifyDeclaredBase(tag, symbol, dataType); + + ResolvedAdsTag resolved = resolvePart(symbol.getGroup(), symbol.getOffset(), dataType, + root.arrayIndices(), root.child(), selection); + return scaleToSelection(resolved, selection); + } + + /** + * Appends the first index of each selected dimension to the deepest segment of the path, so + * that the location the read starts at is resolved by the same code that resolves an index + * written in the path itself. + */ + private static AddressParser.AddressPart withSelectionStart(AddressParser.AddressPart part, + List selection) { + if (selection.isEmpty()) { + return part; + } + if (part.child() != null) { + return new AddressParser.AddressPart(part.baseSegment(), part.arrayIndices(), + withSelectionStart(part.child(), selection)); + } + List indices = new ArrayList<>(part.arrayIndices()); + for (ArrayInfo dimension : selection) { + indices.add(dimension.getLowerBound()); + } + return new AddressParser.AddressPart(part.baseSegment(), indices, null); + } + + /** + * Checks a declared lower bound written in the address against the one the symbol table + * declares. The table is authoritative; a base in the address is the user's statement of + * intent, and a disagreement means the address was written against a different layout than + * the PLC has - which would otherwise read silently shifted data. + * + *

This is the one rule of the notation that cannot be checked while the address is + * parsed, because the symbol table is not loaded then. + */ + private void verifyDeclaredBase(SymbolicAdsTag tag, AdsSymbolTableEntry symbol, + AdsDataTypeTableEntry dataType) { + Integer declared = tag.getDeclaredBase(); + if (declared == null || dataType.getArrayInfo().isEmpty()) { + return; + } + long actual = dataType.getArrayInfo().get(dataType.getArrayInfo().size() - 1).getLowerBound(); + if (declared != actual) { + throw new PlcInvalidTagException(String.format( + "Address '%s' declares the array to start at %d, but %s declares it to start at %d", + tag.getSymbolicAddress(), declared, symbol.getName(), actual)); + } + } + + /** + * Restates a direct tag's selection in the terms the decoder reads, so a direct array read + * returns every element it asked the device for. + * + *

The size of the request is already multiplied by the element count, but the decoder + * builds its lists from {@code remainingArrayInfo}: left empty, it read one element and + * discarded the rest of the response - silently, because a shorter value is still a valid + * one. This is what {@link #resolve} does for a symbolic tag; a direct tag names + * its own location, so its selection is the whole of its shape.

+ */ + public static ResolvedAdsTag withDirectSelection(ResolvedAdsTag resolved, List selection) { + if (selection.isEmpty()) { + return resolved; + } + List dimensions = new ArrayList<>(selection.size()); + for (ArrayInfo dimension : selection) { + dimensions.add(new AdsDataTypeArrayInfo( + (long) dimension.getLowerBound(), (long) dimension.getSize())); + } + return new ResolvedAdsTag(resolved.indexGroup(), resolved.indexOffset(), resolved.sizeInBytes(), + resolved.dataTypeName(), PlcValueType.List, resolved.stringLength(), dimensions); + } + + /** + * Widens a location resolved for a single element to cover the whole selection: the same + * start, as many bytes as the selection spans, decoded as a list. + * + *

The shape follows what the address wrote, dimension by dimension: a range contributes a + * level of list, a bare index moves the start and collapses. So {@code grid[3,1..3]} is a + * flat list of three and {@code grid[1..2,0..4]} is two lists of five, and a range spanning + * one element is still a list of one.

+ */ + private static ResolvedAdsTag scaleToSelection(ResolvedAdsTag resolved, List selection) { + long elements = 1; + // The decoder builds its lists from the ADS array-info shape, so the selection is + // restated in those terms: the dimensions written as ranges, each starting where the + // user asked and spanning as many elements. + List dimensions = new ArrayList<>(selection.size()); + for (ArrayInfo dimension : selection) { + elements *= dimension.getSize(); + if (dimension.isRange()) { + dimensions.add(new AdsDataTypeArrayInfo( + (long) dimension.getLowerBound(), (long) dimension.getSize())); + } + } + if (dimensions.isEmpty()) { + // Every dimension the selection named was a bare index, so it named one element of + // them: a scalar, or - where the selection named only the outer dimensions - the + // whole of what lies inside one, which resolution has already shaped and sized. + return resolved; + } + // A dimension the selection did not name is selected whole, and is still part of the + // shape: grid[1..2] on an ARRAY [0..9,0..4] is two rows of five, not a flat two. Those + // dimensions are what resolution left over, and their bytes are already in sizeInBytes. + dimensions.addAll(resolved.remainingArrayInfo()); + return new ResolvedAdsTag(resolved.indexGroup(), resolved.indexOffset(), + resolved.sizeInBytes() * elements, resolved.dataTypeName(), PlcValueType.List, + resolved.stringLength(), dimensions); + } + + /** + * Holds a trailing selection to what the device declares and to what one read can express. + * + *

A read covers one contiguous run of memory. Scanning outwards from the innermost + * dimension, every dimension inside a dimension selecting more than one element must be + * selected whole: on an {@code ARRAY [0..9,0..4]}, {@code [0..9,1..3]} names ten separate + * three-element runs, and the contiguous block of thirty starting at {@code [0,1]} that one + * read returns is not what was asked for. This refuses it; before, that block was returned.

+ * + *

A selection may name fewer dimensions than the array declares; the ones it does not name + * are selected whole, which is what makes {@code grid[1..2]} two whole rows. Those dimensions + * are contiguous by construction, so only the named ones are checked here.

+ */ + private static void verifySelectionIsOneRead(List selection, + List declared, + String typeName) { + if (selection.size() > declared.size()) { + throw new PlcInvalidTagException(String.format( + "A selection of %d dimension(s) on %s, which declares %d", + selection.size(), typeName, declared.size())); + } + for (int dimension = 0; dimension < selection.size(); dimension++) { + ArrayInfo selected = selection.get(dimension); + AdsDataTypeArrayInfo available = declared.get(dimension); + if (selected.getLowerBound() < available.getLowerBound() + || selected.getUpperBound() > available.getUpperBound()) { + throw new PlcInvalidTagException(String.format( + "Selection [%d..%d] is outside [%d..%d], which %s declares for dimension %d", + selected.getLowerBound(), selected.getUpperBound(), + available.getLowerBound(), available.getUpperBound(), typeName, dimension)); + } + if (dimension == 0 || selected.getSize() == available.getNumElements()) { + continue; + } + for (int outer = 0; outer < dimension; outer++) { + if (selection.get(outer).getSize() > 1) { + throw new PlcInvalidTagException(String.format( + "A selection of part of dimension %d of %s, while dimension %d spans %d" + + " elements, is not one contiguous read - select the whole of the" + + " inner dimension, or one element of the outer one", + dimension, typeName, outer, selection.get(outer).getSize())); + } + } + } } private ResolvedAdsTag resolvePart(long indexGroup, long indexOffset, AdsDataTypeTableEntry dataType, List arrayIndices, - AddressParser.AddressPart child) { + AddressParser.AddressPart child, + List selection) { if (!arrayIndices.isEmpty()) { - return resolveArray(indexGroup, indexOffset, dataType, arrayIndices, child); + return resolveArray(indexGroup, indexOffset, dataType, arrayIndices, child, selection); } if (child != null) { - return resolveChild(indexGroup, indexOffset, dataType, child); + if (!dataType.getArrayInfo().isEmpty()) { + // Omitting the brackets asks for the whole array, so a member access after one + // asks for that member of every element - which is not one contiguous read. The + // partially indexed form is refused in resolveArray for the same reason; this is + // the same rule where no index was given at all. Without it the address would + // silently resolve against the first element. + throw new PlcInvalidTagException( + "Field access requires an array element to be specified for " + + dataType.getMainName() + ": an address that omits the index asks for the" + + " whole array, and a member of every element is not a single read"); + } + return resolveChild(indexGroup, indexOffset, dataType, child, selection); } + // remainingArrayInfo stays empty for a whole-array read: it means "dimensions still to + // be applied", and the decoder reads the full shape from the type table itself. This is + // an internal signal, not the caller-facing report - see SymbolicAdsTag#getArrayInfo. return finalizeLeaf(indexGroup, indexOffset, dataType, Collections.emptyList()); } private ResolvedAdsTag resolveArray(long indexGroup, long indexOffset, AdsDataTypeTableEntry dataType, List arrayIndices, - AddressParser.AddressPart child) { + AddressParser.AddressPart child, + List selection) { + // The selection's own start indices were appended to the deepest segment of the path, so + // this is the array it selects from, and its declared dimensions are the ones to hold it + // to. Deeper segments carry it on; there is nothing to check against here. + if (child == null && !selection.isEmpty()) { + verifySelectionIsOneRead(selection, + dataType.getArrayInfo().subList( + Math.max(0, arrayIndices.size() - selection.size()), dataType.getArrayInfo().size()), + dataType.getMainName()); + } if (dataType.getArrayInfo().isEmpty()) { throw new PlcInvalidTagException( "Array index applied to non-array type " + dataType.getMainName()); @@ -144,7 +331,7 @@ private ResolvedAdsTag resolveArray(long indexGroup, long indexOffset, return primitiveLeaf(indexGroup, indexOffset, elementTypeName, elementSize); } return resolvePart(indexGroup, indexOffset, elementDataType, - Collections.emptyList(), child); + Collections.emptyList(), child, child == null ? Collections.emptyList() : selection); } // Partial-dim read: carry remaining dims so the decoder can produce nested PlcLists. @@ -161,7 +348,8 @@ private ResolvedAdsTag resolveArray(long indexGroup, long indexOffset, private ResolvedAdsTag resolveChild(long indexGroup, long indexOffset, AdsDataTypeTableEntry dataType, - AddressParser.AddressPart child) { + AddressParser.AddressPart child, + List selection) { AdsDataTypeTableEntry fieldEntry = null; for (AdsDataTypeTableEntry c : dataType.getChildren()) { if (c.getMainName().equals(child.baseSegment())) { @@ -184,7 +372,7 @@ private ResolvedAdsTag resolveChild(long indexGroup, long indexOffset, fieldEntry.getSecondaryName(), fieldEntry.getSize()); } return resolvePart(indexGroup, indexOffset + fieldEntry.getOffset(), - fieldType, child.arrayIndices(), child.child()); + fieldType, child.arrayIndices(), child.child(), selection); } private ResolvedAdsTag finalizeLeaf(long indexGroup, long indexOffset, diff --git a/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/tag/DirectAdsStringTag.java b/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/tag/DirectAdsStringTag.java index 557fcd4b30b..3315f7a5730 100644 --- a/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/tag/DirectAdsStringTag.java +++ b/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/tag/DirectAdsStringTag.java @@ -18,6 +18,7 @@ */ package org.apache.plc4x.java.ads.tag; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; import org.apache.plc4x.java.spi.buffers.api.WithOption; import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException; @@ -35,13 +36,19 @@ public class DirectAdsStringTag extends DirectAdsTag implements AdsStringTag { private static final Pattern RESOURCE_STRING_ADDRESS_PATTERN = Pattern.compile("^((0[xX](?[0-9a-fA-F]{1,8}))|(?\\d{1,10}))" + "/((0[xX](?[0-9a-fA-F]{1,8}))|(?\\d{1,10}))" + - ":(?STRING|WSTRING)\\((?\\d{1,3})\\)" + - "(\\[(?\\d{1,10})])?"); + ArrayNotationParser.ARRAY_GROUP + + ":(?STRING|WSTRING)\\((?\\d{1,3})\\)"); private final int stringLength; public DirectAdsStringTag(long indexGroup, long indexOffset, String adsDataTypeName, int stringLength, Integer numberOfElements) { - super(indexGroup, indexOffset, adsDataTypeName, numberOfElements); + this(indexGroup, indexOffset, adsDataTypeName, stringLength, numberOfElements, + (numberOfElements != null) && (numberOfElements > 1)); + } + + public DirectAdsStringTag(long indexGroup, long indexOffset, String adsDataTypeName, int stringLength, + Integer numberOfElements, boolean explicitRange) { + super(indexGroup, indexOffset, adsDataTypeName, numberOfElements, explicitRange); this.stringLength = stringLength; } @@ -69,11 +76,20 @@ public static DirectAdsStringTag of(String address) { String stringLengthString = matcher.group("stringLength"); int stringLength = stringLengthString != null ? Integer.parseInt(stringLengthString) : 0; - String numberOfElementsString = matcher.group("numberOfElements"); - Integer numberOfElements = numberOfElementsString != null - ? parseElementCount(numberOfElementsString) : null; + int[] selection = selectionOf(matcher, address); + // A string element occupies its declared length plus the terminator, doubled for WSTRING - + // the same size AdsTcpConnection computes for the read. The offset counts elements, so it + // has to be multiplied by that or a selection lands inside an earlier string. + indexOffset += (long) selection[0] * bytesPerString(adsDataTypeName, stringLength); + Integer numberOfElements = selection[1]; - return new DirectAdsStringTag(indexGroup, indexOffset, adsDataTypeName, stringLength, numberOfElements); + return new DirectAdsStringTag(indexGroup, indexOffset, adsDataTypeName, stringLength, + numberOfElements, selection[2] == 1); + } + + /** What one string of the declared length occupies, terminator included. */ + private static int bytesPerString(String adsDataTypeName, int stringLength) { + return "WSTRING".equals(adsDataTypeName) ? (stringLength + 1) * 2 : (stringLength + 1); } public static boolean matches(String address) { @@ -82,11 +98,12 @@ public static boolean matches(String address) { @Override public String getAddressString() { - String address = String.format("0x%d/%d:%s(%d)", getIndexGroup(), getIndexOffset(), getPlcDataType(), getStringLength()); - if(getNumberOfElements() != 1) { - address += "[" + getNumberOfElements() + "]"; - } - return address; + // The selection sits before the type, as the pattern accepts it, and the index group is + // rendered in the hex its "0x" claims: "0x%d" printed decimal digits behind a hex prefix, + // so group 16416 came back as 0x16416 - a different address that also carried the removed + // suffix form, and so parsed as nothing at all. + return String.format("0x%X/%d%s:%s(%d)", getIndexGroup(), getIndexOffset(), + ArrayNotationParser.render(getArrayInfo()), getPlcDataType(), getStringLength()); } @Override diff --git a/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/tag/DirectAdsTag.java b/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/tag/DirectAdsTag.java index ef2c973c227..2e33ca33b20 100644 --- a/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/tag/DirectAdsTag.java +++ b/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/tag/DirectAdsTag.java @@ -18,12 +18,15 @@ */ package org.apache.plc4x.java.ads.tag; +import org.apache.plc4x.java.ads.readwrite.AdsDataType; import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; import org.apache.plc4x.java.api.model.ArrayInfo; import org.apache.plc4x.java.api.types.PlcValueType; import org.apache.plc4x.java.spi.buffers.api.WithOption; import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException; import org.apache.plc4x.java.spi.buffers.api.WriteBuffer; +import org.apache.plc4x.java.spi.drivers.model.AddressConstraints; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo; import java.nio.charset.StandardCharsets; @@ -42,7 +45,8 @@ public class DirectAdsTag implements AdsTag { private static final Pattern RESOURCE_ADDRESS_PATTERN = Pattern.compile( "^((0[xX](?[0-9a-fA-F]{1,8}))|(?\\d{1,10}))" + "/((0[xX](?[0-9a-fA-F]{1,8}))|(?\\d{1,10}))" + - ":(?\\w+)(\\[(?\\d{1,10})])?"); + ArrayNotationParser.ARRAY_GROUP + + ":(?\\w+)"); /** An index group, an index offset and a length all travel ADS as four bytes. */ private static final long MAX_UINT32 = 0xFFFFFFFFL; @@ -55,7 +59,18 @@ public class DirectAdsTag implements AdsTag { private final int numberOfElements; + /** + * Whether the address wrote the selection as a range. A one-element range is still a range, + * and the count cannot say which was written. + */ + private final boolean explicitRange; + public DirectAdsTag(long indexGroup, long indexOffset, String adsDataTypeName, Integer numberOfElements) { + this(indexGroup, indexOffset, adsDataTypeName, numberOfElements, (numberOfElements != null) && (numberOfElements > 1)); + } + + public DirectAdsTag(long indexGroup, long indexOffset, String adsDataTypeName, Integer numberOfElements, boolean explicitRange) { + this.explicitRange = explicitRange; this.indexGroup = checkUint32("indexGroup", indexGroup); this.indexOffset = checkUint32("indexOffset", indexOffset); this.adsDataTypeName = Objects.requireNonNull(adsDataTypeName); @@ -106,10 +121,28 @@ public static DirectAdsTag of(long indexGroup, long indexOffset, String adsDataT return new DirectAdsTag(indexGroup, indexOffset, adsDataTypeName, numberOfElements); } + /** + * Resolves the address's array expression to the offset from the index offset and the number + * of elements. An absent expression selects one element at the address itself. + * + * @return {@code {offset, numberOfElements}} + */ + protected static int[] selectionOf(Matcher matcher, String address) { + String expression = matcher.group("array"); + if (expression == null) { + return new int[]{0, 1, 0}; + } + ArrayInfo dimension = ArrayNotationParser + .parse(expression, address, AddressConstraints.SINGLE_DIMENSION).getFirst(); + return new int[]{dimension.getLowerBound() - dimension.getBase(), dimension.getSize(), + dimension.isRange() ? 1 : 0}; + } + public static DirectAdsTag of(String address) { Matcher matcher = RESOURCE_ADDRESS_PATTERN.matcher(address); if (!matcher.matches()) { - throw new PlcInvalidTagException(address, RESOURCE_ADDRESS_PATTERN, "{indexGroup}/{indexOffset}:{adsDataType}([numberOfElements])?"); + throw ArrayNotationParser.invalidAddress(address, + "{indexGroup}/{indexOffset}[selection]:{TYPE} - for example 0x4020/0[0..3]:DINT"); } String indexGroupStringHex = matcher.group("indexGroupHex"); @@ -123,11 +156,36 @@ public static DirectAdsTag of(String address) { String adsDataTypeString = matcher.group("adsDataType"); - String numberOfElementsString = matcher.group("numberOfElements"); - Integer numberOfElements = numberOfElementsString != null - ? parseElementCount(numberOfElementsString) : null; + int[] selection = selectionOf(matcher, address); + // An index offset is a byte offset; the selection counts elements. They are the same + // number only for a one-byte type, so 0x4020/0[3]:DINT would otherwise advance three + // bytes and read from inside the first element. + indexOffset += (long) selection[0] * bytesPerElement(adsDataTypeString, selection[0], address); + Integer numberOfElements = selection[1]; + + return new DirectAdsTag(indexGroup, indexOffset, adsDataTypeString, numberOfElements, + selection[2] == 1); + } - return new DirectAdsTag(indexGroup, indexOffset, adsDataTypeString, numberOfElements); + /** + * The storage size of one element of the named type. + * + *

The device's data-type table is not available while an address is being parsed, so only + * the types ADS defines itself can be measured here. A selection on anything else cannot be + * placed, and is refused rather than silently applied at the wrong offset - the offset is only + * needed when something was selected, so an address without a selection is unaffected.

+ */ + private static int bytesPerElement(String typeName, int offset, String address) { + try { + return AdsDataType.valueOf(typeName).getNumBytes(); + } catch (IllegalArgumentException e) { + if (offset == 0) { + return 1; + } + throw new PlcInvalidTagException("Cannot place a selection in '" + address + "': the size" + + " of type '" + typeName + "' is only known to the device, so the element's offset" + + " cannot be computed here. Address the element directly instead."); + } } public static boolean matches(String address) { @@ -152,11 +210,10 @@ public int getNumberOfElements() { @Override public String getAddressString() { - String address = String.format("0x%d/%d:%s", getIndexGroup(), getIndexOffset(), getPlcDataType()); - if(getNumberOfElements() != 1) { - address += "[" + getNumberOfElements() + "]"; - } - return address; + // "0x%d" printed the group's decimal digits behind a hex prefix, so group 16416 came back + // as 0x16416 - which re-parses as 91158, a different address entirely. + return String.format("0x%X/%d%s:%s", getIndexGroup(), getIndexOffset(), + ArrayNotationParser.render(getArrayInfo()), getPlcDataType()); } @Override @@ -170,8 +227,9 @@ public PlcValueType getPlcValueType() { @Override public List getArrayInfo() { - if(getNumberOfElements() != 1) { - return Collections.singletonList(new DefaultArrayInfo(0, getNumberOfElements())); + // A range is an array even when it spans one element; the count cannot express that. + if (explicitRange) { + return Collections.singletonList(new DefaultArrayInfo(0, getNumberOfElements() - 1, 0, true)); } return Collections.emptyList(); } @@ -181,12 +239,11 @@ public boolean equals(Object o) { if (this == o) { return true; } - if (!(o instanceof DirectAdsTag)) { - return false; + if (o instanceof DirectAdsTag that) { + return indexGroup == that.indexGroup && + indexOffset == that.indexOffset; } - DirectAdsTag that = (DirectAdsTag) o; - return indexGroup == that.indexGroup && - indexOffset == that.indexOffset; + return false; } @Override diff --git a/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/tag/SymbolicAdsTag.java b/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/tag/SymbolicAdsTag.java index 687ae385cf6..441c11a10a6 100644 --- a/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/tag/SymbolicAdsTag.java +++ b/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/tag/SymbolicAdsTag.java @@ -20,6 +20,8 @@ import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; import org.apache.plc4x.java.api.model.ArrayInfo; +import org.apache.plc4x.java.spi.drivers.model.AddressConstraints; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.api.types.PlcValueType; import org.apache.plc4x.java.spi.buffers.api.WithOption; import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException; @@ -40,6 +42,18 @@ public class SymbolicAdsTag implements AdsTag { private static final Pattern SYMBOLIC_ADDRESS_PATTERN = Pattern.compile( "^[a-zA-Z_]\\w*(\\[\\d+\\])*(\\.[a-zA-Z_]\\w*(\\[\\d+\\])*)*$"); + /** + * A range has to be contiguous to be one read. Only the last dimension of the trailing + * selection may span more than one element: "a[1].b[2..5]" is one run of a single + * sub-structure, while "a[1..3].b" would be member b of three separate elements and + * "a[1..3][2]" a strided slice - neither of which any single request can fetch. + * + *

An interior range is already refused by the symbolic path pattern, which accepts only + * bare indices between the dots. + */ + private static final AddressConstraints CONSTRAINTS = + AddressConstraints.UNCONSTRAINED.withOnlyTrailingDimensionMayBeRange(true); + private final String symbolicAddress; private final PlcValueType dataType; @@ -53,15 +67,52 @@ public SymbolicAdsTag(String symbolicAddress, PlcValueType dataType, List selection = expression.isEmpty() + ? Collections.emptyList() + : ArrayNotationParser.parse(expression, address, CONSTRAINTS); + return new SymbolicAdsTag(address, null, selection); } public static boolean matches(String address) { - return SYMBOLIC_ADDRESS_PATTERN.matcher(address).matches(); + return SYMBOLIC_ADDRESS_PATTERN.matcher(ArrayNotationParser.addressPart(address)).matches(); + } + + /** + * The selection the address states, or an empty list where it states none. Derived from the + * address rather than from the constructor, because a tag built directly - as the driver does + * when it browses the symbol table - carries the variable's declared shape in its arrayInfo, + * not the user's selection. + * + *

Distinct from {@link #getArrayInfo()}, which describes the shape of the value the caller + * receives. + */ + public List getSelection() { + String expression = ArrayNotationParser.expressionPart(symbolicAddress); + return expression.isEmpty() + ? Collections.emptyList() + : ArrayNotationParser.parse(expression, symbolicAddress, CONSTRAINTS); + } + + /** + * The declared lower bound the address states for its trailing dimension, or {@code null} + * where it states none. The device's own declaration is authoritative; this is the user's + * statement of intent, to be checked against it when the symbol is resolved. + */ + public Integer getDeclaredBase() { + String expression = ArrayNotationParser.expressionPart(symbolicAddress); + if (expression.isEmpty() || !expression.contains(";")) { + return null; + } + List dimensions = ArrayNotationParser.parse(expression, symbolicAddress); + return dimensions.get(dimensions.size() - 1).getBase(); } public String getSymbolicAddress() { @@ -78,8 +129,18 @@ public PlcValueType getPlcValueType() { return dataType; } + /** + * The shape of the value the caller receives: empty for a scalar, one entry per dimension + * for an array. A bare index selects one element and so reports empty; a range reports its + * dimensions. Where the address states no selection at all, the driver fills this in from + * the symbol table so a bare array address reports the whole declared array. + */ @Override public List getArrayInfo() { + if (ArrayNotationParser.selectsSingleElement( + ArrayNotationParser.expressionPart(symbolicAddress))) { + return Collections.emptyList(); + } return arrayInfo; } diff --git a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/manual/Scanner.java b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/manual/Scanner.java index bfc4fd8a44d..8e1bbd23e6d 100644 --- a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/manual/Scanner.java +++ b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/manual/Scanner.java @@ -67,7 +67,7 @@ public static void main(String[] args) throws Exception { // read symbols System.out.println("Reading symbol info"); PlcReadRequest.Builder readRequestBuilder = plcConnection.readRequestBuilder(); - PlcReadRequest request = readRequestBuilder.addTagAddress("SYM_UPLOADINFO2", "0xf00f/0x0:SINT[24]").build(); + PlcReadRequest request = readRequestBuilder.addTagAddress("SYM_UPLOADINFO2", "0xf00f/0x0[0..23]:SINT").build(); PlcReadResponse rsp = request.execute().get(); ByteBuffer buffer = toBuffer(rsp, "SYM_UPLOADINFO2"); diff --git a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/resolution/TagResolverTest.java b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/resolution/TagResolverTest.java index 9254e151319..0f5673eb3d0 100644 --- a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/resolution/TagResolverTest.java +++ b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/resolution/TagResolverTest.java @@ -21,6 +21,7 @@ import org.apache.plc4x.java.ads.readwrite.AdsDataTypeArrayInfo; import org.apache.plc4x.java.ads.readwrite.AdsDataTypeTableEntry; import org.apache.plc4x.java.ads.readwrite.AdsSymbolTableEntry; +import org.apache.plc4x.java.ads.tag.DirectAdsTag; import org.apache.plc4x.java.ads.tag.SymbolicAdsTag; import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; import org.apache.plc4x.java.api.types.PlcValueType; @@ -118,6 +119,73 @@ void singleIndexedElement_offsetUses1BasedIndex() { assertEquals(PlcValueType.INT, t.plcValueType()); } + /** + * Omitting the selection asks for the whole array (FR-022). ADS knows its extent from the + * symbol table, so it reads all five INTs and reports the declared dimension rather than + * falling back to a single element. + */ + @Test + void aBareArrayAddressReadsTheWholeArray() { + ResolvedAdsTag t = resolver.resolve(new SymbolicAdsTag("MAIN.g_arrInt", null, List.of())); + + assertEquals(0x200, t.indexOffset(), "the start of the array"); + assertEquals(10, t.sizeInBytes(), "five INTs"); + assertEquals(PlcValueType.List, t.plcValueType(), "decoded as a list, not a scalar"); + // remainingArrayInfo means "dimensions still to apply" and is empty for a whole-array + // read; the decoder takes the shape from the type table. See wholeArrayRead_noRemainingDims. + assertTrue(t.remainingArrayInfo().isEmpty()); + } + + /** A bare index is one element, so it reads one and is a scalar - the contrast to the above. */ + @Test + void aBareIndexReadsOneElement() { + ResolvedAdsTag t = resolver.resolve(new SymbolicAdsTag("MAIN.g_arrInt[3]", null, List.of())); + + assertEquals(2, t.sizeInBytes(), "one INT"); + assertEquals(PlcValueType.INT, t.plcValueType()); + assertTrue(t.remainingArrayInfo().isEmpty()); + } + + /** + * A range reads several elements from where it starts. MAIN.g_arrInt is declared 1..5 of INT, + * so [2..4] begins one element in and covers three of them. + */ + @Test + void aRangeReadsFromItsStartForAsManyElementsAsItSpans() { + ResolvedAdsTag t = resolver.resolve(new SymbolicAdsTag("MAIN.g_arrInt[2..4]", null, List.of())); + + assertEquals(0x200 + 2, t.indexOffset(), "starts at the second element"); + assertEquals(6, t.sizeInBytes(), "three INTs"); + assertEquals(PlcValueType.List, t.plcValueType()); + assertEquals(3, t.remainingArrayInfo().get(0).getNumElements()); + } + + /** A single-element range still yields a list, unlike a bare index. */ + @Test + void aSingleElementRangeIsStillARange() { + ResolvedAdsTag t = resolver.resolve(new SymbolicAdsTag("MAIN.g_arrInt[2..2]", null, List.of())); + assertEquals(2, t.sizeInBytes()); + } + + /** + * A declared lower bound written in the address is checked against the symbol table, which is + * authoritative. Agreeing is redundant but harmless; disagreeing means the address was written + * against a different layout than the PLC has. + */ + @Test + void aDeclaredBaseThatMatchesTheSymbolTableIsAccepted() { + ResolvedAdsTag t = resolver.resolve(new SymbolicAdsTag("MAIN.g_arrInt[3;1]", null, List.of())); + assertEquals(0x200 + 4, t.indexOffset(), "resolved exactly as without the base"); + } + + @Test + void aDeclaredBaseThatContradictsTheSymbolTableIsRejected() { + PlcInvalidTagException thrown = assertThrows(PlcInvalidTagException.class, + () -> resolver.resolve(new SymbolicAdsTag("MAIN.g_arrInt[3;0]", null, List.of()))); + assertTrue(thrown.getMessage().contains("start at 0"), thrown::getMessage); + assertTrue(thrown.getMessage().contains("start at 1"), thrown::getMessage); + } + @Test void firstAndLastElementBoundsAreInclusive() { ResolvedAdsTag first = resolver.resolve(new SymbolicAdsTag("MAIN.g_arrInt[1]", null, List.of())); @@ -147,6 +215,66 @@ void multiDim_partialIndex_returnsRowSlice() { assertEquals(3, dim.getNumElements()); } + /** + * A range before the last dimension is refused while the address is parsed: ADS carries one + * element count for the whole address, so [1..2,2..3] would ask for two elements of each of + * two rows in a single count. This is the guarantee the resolver's own contiguity check + * stands behind, for a tag built without going through the parser. + */ + @Test + void multiDim_rangeBeforeTheLastDimensionRejected() { + assertThrows(PlcInvalidTagException.class, + () -> SymbolicAdsTag.of("MAIN.g_matI16_2x3[1..2,2..3]")); + } + + /** A bare index collapses, so row 2 in full is a flat list of three. */ + @Test + void multiDim_oneWholeRow() { + ResolvedAdsTag t = resolver.resolve( + new SymbolicAdsTag("MAIN.g_matI16_2x3[2,1..3]", null, List.of())); + + assertEquals(0x300 + 6, t.indexOffset(), "the second row"); + assertEquals(6, t.sizeInBytes()); + assertEquals(1, t.remainingArrayInfo().size(), "the row index collapses"); + assertEquals(3, t.remainingArrayInfo().get(0).getNumElements()); + } + + /** Part of one row is still one run of memory. */ + @Test + void multiDim_partOfOneRow() { + ResolvedAdsTag t = resolver.resolve( + new SymbolicAdsTag("MAIN.g_matI16_2x3[2,2..3]", null, List.of())); + + assertEquals(0x300 + 6 + 2, t.indexOffset(), "row 2, column 2"); + assertEquals(4, t.sizeInBytes(), "two elements"); + assertEquals(1, t.remainingArrayInfo().size()); + assertEquals(2, t.remainingArrayInfo().get(0).getNumElements()); + } + + /** + * A selection may name only the outer dimensions; the rest are selected whole. The dimensions + * it did not name stay in the shape - two rows of three, not a flat two, whose bytes would + * have been transferred and then dropped. + */ + @Test + void multiDim_selectionOfRowsKeepsTheColumns() { + ResolvedAdsTag t = resolver.resolve( + new SymbolicAdsTag("MAIN.g_matI16_2x3[1..2]", null, List.of())); + + assertEquals(0x300, t.indexOffset()); + assertEquals(12, t.sizeInBytes(), "two whole rows"); + assertEquals(2, t.remainingArrayInfo().size(), "the unnamed dimension is still a dimension"); + assertEquals(2, t.remainingArrayInfo().get(0).getNumElements()); + assertEquals(3, t.remainingArrayInfo().get(1).getNumElements()); + } + + /** Bounds are held per dimension, not only on the first. */ + @Test + void multiDim_outOfBoundsColumnRejected() { + assertThrows(PlcInvalidTagException.class, + () -> resolver.resolve(new SymbolicAdsTag("MAIN.g_matI16_2x3[1,2..4]", null, List.of()))); + } + @Test void multiDim_fullIndex_returnsScalar() { // matrix[2][3] is the last element: row 1 (0-based) * 6 bytes + col 2 * 2 = 6 + 4 = 10. @@ -191,6 +319,26 @@ void unknownSymbolRejected() { () -> resolver.resolve(new SymbolicAdsTag("MAIN.unknown", null, List.of()))); } + /** + * Omitting the brackets asks for the whole array, so a member access after one asks for that + * member of every element - which is not a single read. Without this the address would + * resolve silently against the first element and report data for one channel as though it + * were the whole path. + */ + @Test + void aMemberOfAnUnindexedArrayIsRejected() { + PlcInvalidTagException thrown = assertThrows(PlcInvalidTagException.class, + () -> resolver.resolve(new SymbolicAdsTag("MAIN.g_plant.channels.setpoints", null, List.of()))); + assertTrue(thrown.getMessage().contains("whole array"), thrown::getMessage); + } + + /** The whole array itself is still addressable - it is only a member of it that is not. */ + @Test + void theWholeArrayItselfIsStillAddressable() { + assertDoesNotThrow( + () -> resolver.resolve(new SymbolicAdsTag("MAIN.g_plant.channels", null, List.of()))); + } + @Test void deepMixedPath_resolvesElementInsideArrayOfStructs() { // plant.channels[2].setpoints[3]: @@ -268,4 +416,36 @@ void extractStringLength_parsesParens() { assertEquals(0, TagResolver.extractStringLength(null)); assertEquals(0, TagResolver.extractStringLength("STRING(abc)")); } + + /** + * A direct array tag asks the device for every element it selected, so it has to decode every + * one of them. The request size was already multiplied by the count while the decoder was + * given no shape at all, so the extra elements were read from the wire and dropped - a short + * value that looks like a valid one. + */ + @Test + void aDirectSelectionBecomesTheDecodedShape() { + ResolvedAdsTag scalar = new ResolvedAdsTag(0x4020, 0, 16, "DINT", PlcValueType.DINT, + 0, List.of()); + + ResolvedAdsTag shaped = TagResolver.withDirectSelection(scalar, + DirectAdsTag.of("0x4020/0[0..3]:DINT").getArrayInfo()); + + assertEquals(PlcValueType.List, shaped.plcValueType(), "decoded as a list"); + assertEquals(1, shaped.remainingArrayInfo().size(), "one dimension"); + assertEquals(4, shaped.remainingArrayInfo().get(0).getNumElements(), "all four elements"); + assertEquals(16, shaped.sizeInBytes(), "the request size is unchanged"); + } + + @Test + void aDirectScalarKeepsItsScalarShape() { + ResolvedAdsTag scalar = new ResolvedAdsTag(0x4020, 0, 4, "DINT", PlcValueType.DINT, + 0, List.of()); + + ResolvedAdsTag shaped = TagResolver.withDirectSelection(scalar, + DirectAdsTag.of("0x4020/0[3]:DINT").getArrayInfo()); + + assertEquals(PlcValueType.DINT, shaped.plcValueType()); + assertTrue(shaped.remainingArrayInfo().isEmpty()); + } } diff --git a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/AdsArrayParityTest.java b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/AdsArrayParityTest.java new file mode 100644 index 00000000000..3390e6e67dc --- /dev/null +++ b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/AdsArrayParityTest.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.ads.tag; + +import org.apache.plc4x.java.api.model.ArrayInfo; +import org.apache.plc4x.java.ads.tag.DirectAdsTag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The same selection means the same thing here as on every other driver (SC-001). + * + *

Each driver asserts this against its own address syntax; the assertions are deliberately + * identical, because that sameness is the success criterion. + */ +class AdsArrayParityTest { + + @Test + void aRangeOfEightStartingAtZero() { + List dimensions = DirectAdsTag.of("0x4020/0[0..7]:INT").getArrayInfo(); + + assertEquals(1, dimensions.size(), "one dimension"); + assertEquals(8, dimensions.get(0).getSize(), "eight elements"); + assertEquals(0, dimensions.get(0).getLowerBound() - dimensions.get(0).getBase(), + "starting at offset zero"); + assertTrue(dimensions.get(0).isRange(), "written as a range"); + } + + @Test + void aBareIndexIsAScalar() { + assertTrue(DirectAdsTag.of("0x4020/0[4]:INT").getArrayInfo().isEmpty()); + } + + /** + * The case the notation exists to distinguish: a range spanning one element is an array of + * one, while a bare index is a scalar. No element count can tell them apart, so a driver that + * derives its shape from the count alone silently collapses them - which is how the SLMP tag + * reported a one-element range as a scalar while plc4go's reported a list. + */ + @Test + void aOneElementRangeIsAnArrayOfOne() { + List dimensions = DirectAdsTag.of("0x4020/0[4..4]:INT").getArrayInfo(); + + assertEquals(1, dimensions.size(), "one dimension"); + assertEquals(1, dimensions.get(0).getSize(), "one element"); + assertTrue(dimensions.get(0).isRange(), "written as a range"); + + assertTrue(DirectAdsTag.of("0x4020/0[4]:INT").getArrayInfo().isEmpty(), + "while the bare index of the same element stays a scalar"); + } + + @Test + void anOmittedSelectionIsAScalar() { + assertTrue(DirectAdsTag.of("0x4020/0:INT").getArrayInfo().isEmpty()); + } +} diff --git a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/AdsLegacyAddressTest.java b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/AdsLegacyAddressTest.java new file mode 100644 index 00000000000..1eca9a24803 --- /dev/null +++ b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/AdsLegacyAddressTest.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.ads.tag; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.ads.tag.DirectAdsTag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Addresses written before the array notation was unified must not parse. + * + *

The brackets moved from after the type to before it, which is what turns an otherwise + * silent change of meaning into a failure: {@code [4]} used to mean four elements and now means + * the fifth, so an unmodified address that still parsed would quietly return different data. + */ +class AdsLegacyAddressTest { + + @Test + void legacyForm0IsRejected() { + assertThrows(PlcInvalidTagException.class, () -> DirectAdsTag.of("0x4020/0:DINT[4]")); + } + + @Test + void legacyForm1IsRejected() { + assertThrows(PlcInvalidTagException.class, () -> DirectAdsTag.of("1/2:INT[8]")); + } + + @Test + void theReplacementFormParses() { + assertNotNull(DirectAdsTag.of("0x4020/0[0..3]:DINT")); + } + + /** The message has to hand an upgrading user the address to write, not just a regex. */ + @Test + void theErrorNamesTheReplacementAddress() { + PlcInvalidTagException thrown = + assertThrows(PlcInvalidTagException.class, () -> DirectAdsTag.of("0x4020/0:DINT[4]")); + assertTrue(thrown.getMessage().contains("0x4020/0[0..3]:DINT"), thrown::getMessage); + } +} diff --git a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/AdsSelectionOffsetTest.java b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/AdsSelectionOffsetTest.java new file mode 100644 index 00000000000..9e27ce424f4 --- /dev/null +++ b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/AdsSelectionOffsetTest.java @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.ads.tag; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * An ADS index offset is a byte offset, while a selection offset counts elements. They + * coincide only for a one-byte type, so an unscaled offset reads from inside an earlier element. + */ +class AdsSelectionOffsetTest { + + @Test + @DisplayName("a one-byte type advances one byte per element") + void oneBytePerElement() { + assertEquals(3, DirectAdsTag.of("0x4020/0[3]:BYTE").getIndexOffset()); + } + + @Test + @DisplayName("a four-byte type advances four bytes per element") + void fourBytesPerElement() { + // The fourth DINT begins twelve bytes along, not three. + assertEquals(12, DirectAdsTag.of("0x4020/0[3]:DINT").getIndexOffset()); + } + + @Test + @DisplayName("an eight-byte type advances eight bytes per element") + void eightBytesPerElement() { + assertEquals(16, DirectAdsTag.of("0x4020/0[2]:LREAL").getIndexOffset()); + } + + @Test + @DisplayName("a range starts where its first element starts") + void aRangeStartsAtItsFirstElement() { + assertEquals(8, DirectAdsTag.of("0x4020/0[2..5]:DINT").getIndexOffset()); + assertEquals(4, DirectAdsTag.of("0x4020/0[2..5]:DINT").getNumberOfElements()); + } + + @Test + @DisplayName("a string advances by its declared length plus its terminator") + void stringsAdvanceByTheirStorageSize() { + // A STRING(10) occupies 11 bytes, so the third begins 22 bytes along. + assertEquals(22, DirectAdsStringTag.of("0x4020/0[2]:STRING(10)").getIndexOffset()); + // A WSTRING(10) occupies 22, so the third begins 44 bytes along. + assertEquals(44, DirectAdsStringTag.of("0x4020/0[2]:WSTRING(10)").getIndexOffset()); + } + + @Test + @DisplayName("a selection on a type whose width is unknown here is refused, not guessed") + void anUnknownWidthIsRefused() { + // The device's data-type table is not available while parsing an address, so there is no + // size to place the element with. Reading from the wrong offset would be worse than saying so. + PlcInvalidTagException thrown = assertThrows(PlcInvalidTagException.class, + () -> DirectAdsTag.of("0x4020/0[3]:MY_UDT")); + assertTrue(thrown.getMessage().contains("MY_UDT"), thrown.getMessage()); + } + + @Test + @DisplayName("without a selection an unknown type is still fine") + void anUnknownTypeWithoutASelectionIsUnaffected() { + assertEquals(0, DirectAdsTag.of("0x4020/0:MY_UDT").getIndexOffset()); + } + + @Test + @DisplayName("a bare index is a scalar; a one-element range is a list of one") + void aRangeIsAnArrayEvenWhenItSpansOneElement() { + assertTrue(DirectAdsTag.of("0x4020/0[4]:DINT").getArrayInfo().isEmpty(), + "a bare index selects one element, which is a scalar"); + assertEquals(1, DirectAdsTag.of("0x4020/0[4..4]:DINT").getArrayInfo().size(), + "a range is an array even when it spans one element"); + assertTrue(DirectAdsTag.of("0x4020/0[4..4]:DINT").getArrayInfo().get(0).isRange()); + } + + @Test + @DisplayName("a string tag renders an address that parses back") + void theStringTagRoundTrips() { + // It used to render "0x16416/0:STRING(10)[4]" - decimal digits behind a hex prefix, and + // the suffix form the pattern no longer accepts. Neither survives a re-parse. + DirectAdsStringTag tag = DirectAdsStringTag.of("0x4020/0[0..3]:STRING(10)"); + assertEquals("0x4020/0[0..3]:STRING(10)", tag.getAddressString()); + assertEquals(tag, DirectAdsStringTag.of(tag.getAddressString())); + } + + @Test + @DisplayName("a direct tag renders an address that parses back to the same tag") + void theDirectTagRoundTrips() { + // 16416 is 0x4020. Rendered as "0x16416" it re-parses as 91158 - the driver would read a + // different memory location than the one the tag names. + DirectAdsTag tag = DirectAdsTag.of("0x4020/0[0..3]:DINT"); + assertEquals("0x4020/0[0..3]:DINT", tag.getAddressString()); + assertEquals(tag, DirectAdsTag.of(tag.getAddressString())); + assertEquals(0x4020, DirectAdsTag.of(tag.getAddressString()).getIndexGroup()); + } +} diff --git a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/DirectAdsStringTagTest.java b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/DirectAdsStringTagTest.java index ceedf0ecaa8..04403bacdb8 100644 --- a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/DirectAdsStringTagTest.java +++ b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/DirectAdsStringTagTest.java @@ -40,7 +40,7 @@ void ofParsesStringAddress() { @Test void ofParsesHexStringAddressWithArray() { - DirectAdsStringTag tag = DirectAdsStringTag.of("0x10/0x20:WSTRING(40)[3]"); + DirectAdsStringTag tag = DirectAdsStringTag.of("0x10/0x20[0..2]:WSTRING(40)"); assertEquals(0x10L, tag.getIndexGroup()); assertEquals(0x20L, tag.getIndexOffset()); assertEquals("WSTRING", tag.getPlcDataType()); @@ -70,8 +70,11 @@ void matchesValidAndInvalid() { void getAddressStringFormatsBase() { DirectAdsStringTag single = DirectAdsStringTag.of(1, 2, "STRING", 10, 1); assertFalse(single.getAddressString().contains("[")); + // The selection is written as the range it is, before the type - not as the trailing count + // this used to assert, which the address pattern no longer accepts. DirectAdsStringTag array = DirectAdsStringTag.of(1, 2, "STRING", 10, 4); - assertTrue(array.getAddressString().contains("[4]")); + assertTrue(array.getAddressString().contains("[0..3]"), array.getAddressString()); + assertTrue(array.getAddressString().endsWith(":STRING(10)"), array.getAddressString()); } @Test diff --git a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/DirectAdsTagBoundTest.java b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/DirectAdsTagBoundTest.java index 85f06b99717..717cf8f0286 100644 --- a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/DirectAdsTagBoundTest.java +++ b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/DirectAdsTagBoundTest.java @@ -32,7 +32,7 @@ public class DirectAdsTagBoundTest { @Test void aCountTooWideToBeANumberIsAnInvalidTagNotANumberFormatError() { - assertThrows(PlcInvalidTagException.class, () -> DirectAdsTag.of("1/2:INT[99999999999]")); + assertThrows(PlcInvalidTagException.class, () -> DirectAdsTag.of("1/2[0..99999999998]:INT")); } @Test @@ -59,7 +59,7 @@ void theLastIndexGroupFourBytesCanHoldIsStillAccepted() { @Test void aStringTagCountTooWideToBeANumberIsAlsoAnInvalidTag() { assertThrows(PlcInvalidTagException.class, - () -> DirectAdsStringTag.of("1/2:STRING(80)[99999999999]")); + () -> DirectAdsStringTag.of("1/2[0..99999999998]:STRING(80)")); } @Test @@ -69,8 +69,13 @@ void aStringTagIndexGroupPastFourBytesIsAlsoRejected() { } @Test - void aCountOfZeroIsNotACountOfElements() { - assertThrows(PlcInvalidTagException.class, () -> DirectAdsTag.of("1/2:INT[0]")); + /** + * A count of zero used to be rejected. The notation cannot express it: [0] names the element + * at offset 0, which is one element, and an empty bracket names nothing at all. + */ + void anEmptySelectionIsRefused() { + assertEquals(1, DirectAdsTag.of("1/2[0]:INT").getNumberOfElements()); + assertThrows(PlcInvalidTagException.class, () -> DirectAdsTag.of("1/2[]:INT")); } @Test @@ -87,7 +92,7 @@ void anIndexOffsetPastFourBytesHandedInDirectlyIsAlsoRefused() { @Test void hexAddressesAreReadAsHex() { - DirectAdsTag tag = DirectAdsTag.of("0x10/0xFF:INT[2]"); + DirectAdsTag tag = DirectAdsTag.of("0x10/0xFF[0..1]:INT"); assertEquals(0x10, tag.getIndexGroup()); assertEquals(0xFF, tag.getIndexOffset()); assertEquals(2, tag.getNumberOfElements()); @@ -95,7 +100,7 @@ void hexAddressesAreReadAsHex() { @Test void aStringTagSharesTheSameChecks() { - DirectAdsStringTag tag = DirectAdsStringTag.of("0x10/2:STRING(80)[3]"); + DirectAdsStringTag tag = DirectAdsStringTag.of("0x10/2[0..2]:STRING(80)"); assertEquals(0x10, tag.getIndexGroup()); assertEquals(2, tag.getIndexOffset()); assertEquals(3, tag.getNumberOfElements()); @@ -103,20 +108,21 @@ void aStringTagSharesTheSameChecks() { } @Test - void aStringTagCountOfZeroIsAlsoRefused() { + void aStringTagEmptySelectionIsAlsoRefused() { + assertEquals(1, DirectAdsStringTag.of("1/2[0]:STRING(80)").getNumberOfElements()); assertThrows(PlcInvalidTagException.class, - () -> DirectAdsStringTag.of("1/2:STRING(80)[0]")); + () -> DirectAdsStringTag.of("1/2[]:STRING(80)")); } @Test void aCountThatWouldNotFitAnIntIsRefused() { // Ten digits match the pattern but do not fit the int the count is kept in. - assertThrows(PlcInvalidTagException.class, () -> DirectAdsTag.of("1/2:INT[3000000000]")); + assertThrows(PlcInvalidTagException.class, () -> DirectAdsTag.of("1/2[0..2999999999]:INT")); } @Test void aPlausibleTagStillParses() { - DirectAdsTag tag = DirectAdsTag.of("1/2:INT[4]"); + DirectAdsTag tag = DirectAdsTag.of("1/2[0..3]:INT"); assertEquals(4, tag.getNumberOfElements()); assertEquals(1, tag.getIndexGroup()); assertEquals(2, tag.getIndexOffset()); diff --git a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/DirectAdsTagTest.java b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/DirectAdsTagTest.java index fed78248154..e3adbc7286f 100644 --- a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/DirectAdsTagTest.java +++ b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/DirectAdsTagTest.java @@ -40,7 +40,7 @@ void ofParsesDecimalAddress() { @Test void ofParsesHexAddress() { - DirectAdsTag tag = DirectAdsTag.of("0x4040/0xFF:LREAL[5]"); + DirectAdsTag tag = DirectAdsTag.of("0x4040/0xFF[0..4]:LREAL"); assertEquals(0x4040L, tag.getIndexGroup()); assertEquals(0xFFL, tag.getIndexOffset()); assertEquals(5, tag.getNumberOfElements()); @@ -85,7 +85,8 @@ void getAddressStringFormatsBase() { DirectAdsTag single = DirectAdsTag.of(1, 2, "DINT", 1); assertFalse(single.getAddressString().contains("[")); DirectAdsTag array = DirectAdsTag.of(1, 2, "DINT", 4); - assertTrue(array.getAddressString().contains("[4]")); + // Rendered in the shared notation: four elements from offset 0. + assertTrue(array.getAddressString().contains("[0..3]")); } @Test diff --git a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/SymbolicAdsTagRangeTest.java b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/SymbolicAdsTagRangeTest.java new file mode 100644 index 00000000000..ee8ca9f1ab8 --- /dev/null +++ b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/tag/SymbolicAdsTagRangeTest.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.ads.tag; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Where a range may appear in a symbolic address. + * + *

A range has to be contiguous to be a single read. Only the last dimension of the trailing + * selection qualifies: everything before it names one element of one structure, and a range + * there would ask for several disjoint pieces at once - which no single request can fetch. + */ +class SymbolicAdsTagRangeTest { + + @Test + void aRangeOnTheLastSegmentIsAccepted() { + assertEquals(8, SymbolicAdsTag.of("MAIN.g_arr[1..8]").getSelection().get(0).getSize()); + assertEquals(4, SymbolicAdsTag.of("MAIN.g_arr[1].member[2..5]").getSelection().get(0).getSize()); + } + + @Test + void bareIndicesOnInteriorSegmentsAreAccepted() { + assertNotNull(SymbolicAdsTag.of("MAIN.g_arr[1].member[2]")); + assertNotNull(SymbolicAdsTag.of("MAIN.g_arr[1][2].member")); + } + + /** Member b of elements 1 to 3 is three separate reads, so the address is refused. */ + @Test + void aRangeOnAnInteriorSegmentIsRefused() { + assertThrows(PlcInvalidTagException.class, () -> SymbolicAdsTag.of("MAIN.g_arr[1..3].member")); + } + + /** A strided slice of a multi-dimensional array is not contiguous either. */ + @Test + void aRangeOnAnythingButTheLastDimensionIsRefused() { + assertThrows(PlcInvalidTagException.class, () -> SymbolicAdsTag.of("MAIN.g_arr[1..3][2]")); + assertNotNull(SymbolicAdsTag.of("MAIN.g_arr[1][2..5]"), "the last dimension may span"); + } + + @Test + void anAddressWithNoSelectionStatesNone() { + assertTrue(SymbolicAdsTag.of("MAIN.g_arr").getSelection().isEmpty()); + } +} diff --git a/plc4j/drivers/eip/src/main/java/org/apache/plc4x/java/eip/base/EipTcpConnection.java b/plc4j/drivers/eip/src/main/java/org/apache/plc4x/java/eip/base/EipTcpConnection.java index eb7fda17067..93365320e30 100644 --- a/plc4j/drivers/eip/src/main/java/org/apache/plc4x/java/eip/base/EipTcpConnection.java +++ b/plc4j/drivers/eip/src/main/java/org/apache/plc4x/java/eip/base/EipTcpConnection.java @@ -914,8 +914,8 @@ private byte[] toAnsi(EipTag tag) throws BufferException { int lengthBytes = 0; for (EipTag.PathElement element : elements) { PathSegment segment; - if (element instanceof EipTag.MemberElement member) { - segment = new LogicalSegment(new MemberID((byte) 0x00, member.index())); + if (element instanceof EipTag.MemberElement(short index)) { + segment = new LogicalSegment(new MemberID((byte) 0x00, index)); } else { String name = ((EipTag.SymbolElement) element).name(); segment = new DataSegment(new AnsiExtendedSymbolSegment(name, @@ -936,7 +936,7 @@ private PlcReadResponse decodeReadResponse(CipService p, PlcReadRequest readRequ List sendable = sendableTagNames(readRequest); if (p instanceof CipReadResponse resp) { if (sendable.isEmpty()) { - return new DefaultPlcReadResponse((DefaultPlcReadRequest) readRequest, values); + return new DefaultPlcReadResponse(readRequest, values); } String tagName = sendable.getFirst(); EipTag tag = (EipTag) readRequest.getTag(tagName); @@ -1117,8 +1117,14 @@ static PlcValue parsePlcValue(EipTag tag, byte[] rawData, CIPDataTypeCode type) int nb = tag.getElementNb(); TypeCodec codec = FIXED_SIZE_CODECS.get(type); + // Whether the caller gets a list is decided by the shape the tag reports, not by how many + // elements it holds: "%arr[4..4]" selects one element and is still a list of one, while + // "%arr[4]" is a scalar. Deciding from the count alone made the response contradict + // getArrayInfo(), which is what a consumer reads to tell the two apart. + boolean asList = !tag.getArrayInfo().isEmpty(); + // Handle array reads. - if (nb > 1) { + if (asList) { if (codec == null) { // STRING and STRUCTURED carry their own length rather than being laid out element // by element, so only the first one can be decoded from the reply. @@ -1140,7 +1146,7 @@ static PlcValue parsePlcValue(EipTag tag, byte[] rawData, CIPDataTypeCode type) return new PlcList(list); } - // If it wasn't an array, then handle single-element reads, which are not wrapped in a PlcList. + // Not an array: a single element, which is not wrapped in a PlcList. if (codec != null) { if (rawData.length < type.getSize()) { LOGGER.warn("Device returned {} bytes for tag '{}', expected {} for a {}.", diff --git a/plc4j/drivers/eip/src/main/java/org/apache/plc4x/java/eip/base/tag/EipTag.java b/plc4j/drivers/eip/src/main/java/org/apache/plc4x/java/eip/base/tag/EipTag.java index fb9634b6c02..ae7814c389a 100644 --- a/plc4j/drivers/eip/src/main/java/org/apache/plc4x/java/eip/base/tag/EipTag.java +++ b/plc4j/drivers/eip/src/main/java/org/apache/plc4x/java/eip/base/tag/EipTag.java @@ -21,6 +21,9 @@ import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; import org.apache.plc4x.java.api.model.ArrayInfo; +import org.apache.plc4x.java.spi.drivers.model.AddressConstraints; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; +import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo; import org.apache.plc4x.java.api.model.PlcTag; import org.apache.plc4x.java.api.types.PlcValueType; import org.apache.plc4x.java.eip.readwrite.CIPDataTypeCode; @@ -38,8 +41,15 @@ public class EipTag implements PlcTag, Serializable { - private static final Pattern ADDRESS_PATTERN = - Pattern.compile("^(?[%a-zA-Z_.0-9]+\\[?[0-9]*]?):?(?[A-Z]*):?(?[0-9]*)"); + private static final Pattern ADDRESS_PATTERN = Pattern.compile( + "^(?[%a-zA-Z_.0-9]+)" + + ArrayNotationParser.ARRAY_GROUP + + "(?::(?[A-Z]+))?$"); + + /** What a CIP request can encode of a selection: one dimension, starting no later than 255. */ + private static final AddressConstraints CONSTRAINTS = AddressConstraints.SINGLE_DIMENSION + .withMaxIndex(255) + .withOnlyTrailingDimensionMayBeRange(true); /** * Splits an address into the members it names. A qualifier of '[' introduces an array @@ -50,7 +60,7 @@ public class EipTag implements PlcTag, Serializable { private static final Pattern PATH_PATTERN = Pattern.compile("([.\\[\\]])*([A-Za-z_0-9]+)"); private static final String GROUP_NAME_TAG = "tag"; - private static final String GROUP_NAME_GROUP_NAME_ELEMENTS = "elementNb"; + private static final String GROUP_NAME_ARRAY = "array"; private static final String GROUP_NAME_TYPE = "dataType"; /** One step of the CIP path an address describes. */ @@ -65,9 +75,17 @@ public record SymbolElement(String name) implements PathElement { public record MemberElement(short index) implements PathElement { } + /** A CIP request carries its element count in 16 bits. */ + private static final long MAX_ELEMENTS = 65535L; + private final String tag; private final CIPDataTypeCode type; - private final int elementNb; + /** + * What the address selects. Drives the CIP path and the element count. Not what + * {@link #getArrayInfo()} reports - see there. + */ + private final List selection; + private final boolean scalarSelection; private final List pathElements; public EipTag(String tag) { @@ -82,34 +100,84 @@ public EipTag(String tag, CIPDataTypeCode type) { this(tag, type, 1); } + /** + * Builds a tag selecting {@code elementNb} elements from the start of {@code tag}, which is + * the shape the older constructors describe. An address selecting a range is built through + * {@link #of(String)}. + */ public EipTag(String tag, CIPDataTypeCode type, int elementNb) { + this(tag, type, rangeOf(tag, Math.max(elementNb, 1))); + } + + public EipTag(String tag, CIPDataTypeCode type, List selection) { this.tag = tag; this.type = type; - // A request always asks for at least one element; normalising here keeps every use site - // from having to guard against a count of zero. - this.elementNb = Math.max(elementNb, 1); - this.pathElements = decomposePath(tag); + this.selection = selection == null ? Collections.emptyList() : List.copyOf(selection); + this.scalarSelection = + ArrayNotationParser.selectsSingleElement(ArrayNotationParser.expressionPart(tag)); + this.pathElements = decomposePath(tag, this.selection); + // A CIP read request carries the element count in 16 bits, so a larger selection is + // narrowed on the way out - [0..65535] would ask the device for zero elements. Refuse it + // here rather than send a request that means something else. + long elements = 1; + for (ArrayInfo dimension : this.selection) { + elements *= dimension.getSize(); + } + if (elements > MAX_ELEMENTS) { + throw new PlcInvalidTagException("Tag '" + tag + "' selects " + elements + + " elements, more than the " + MAX_ELEMENTS + " a CIP request can ask for."); + } + } + + /** + * The selection an element count describes on its own: the first {@code count} elements, + * unless the address itself already names a starting index. + */ + private static List rangeOf(String tag, int count) { + String expression = tag == null ? "" : ArrayNotationParser.expressionPart(tag); + int start = 0; + if (!expression.isEmpty()) { + ArrayInfo first = ArrayNotationParser.parse(expression, tag, CONSTRAINTS).get(0); + start = first.getLowerBound() - first.getBase(); + } else if (count <= 1) { + return Collections.emptyList(); + } + return List.of(new DefaultArrayInfo(start, start + count - 1)); } - private static List decomposePath(String tag) { + /** + * The CIP path of an address: one segment per member named in it, followed by a member + * segment for the element the selection starts at. A structured address yields one segment + * per member - {@code a.b} is two symbols, not one symbol named "a.b" - because that is how + * a controller walks a path. + * + *

A selection given as a bare element count carries no member segment: the request starts + * at the tag itself and asks for that many elements. + */ + private static List decomposePath(String tag, List arrayInfo) { if (tag == null) { return Collections.emptyList(); } - Matcher matcher = PATH_PATTERN.matcher(tag); + Matcher matcher = PATH_PATTERN.matcher(ArrayNotationParser.addressPart(tag)); List elements = new ArrayList<>(2); while (matcher.find()) { - String identifier = matcher.group(2); if ("[".equals(matcher.group(1))) { - // MemberID.instance is a uint 8 in the mspec, so it holds 0 to 255. Reject a - // larger index here rather than letting it fail later during serialization. - int index = Integer.parseInt(identifier); - if (index > 255) { - throw new PlcInvalidTagException(String.format( - "Error parsing address %s, index %d is out of range 0 to 255", tag, index)); - } - elements.add(new MemberElement((short) index)); + // An index written inside the path rather than as a trailing selection, as in + // "a[2].b". It addresses one element, so it is a member segment like any other. + elements.add(new MemberElement(Short.parseShort(matcher.group(2)))); } else { - elements.add(new SymbolElement(identifier)); + elements.add(new SymbolElement(matcher.group(2))); + } + } + if (!ArrayNotationParser.expressionPart(tag).isEmpty() && !arrayInfo.isEmpty()) { + ArrayInfo first = arrayInfo.get(0); + short offset = (short) (first.getLowerBound() - first.getBase()); + // A member segment says where the selection starts. Starting at the first element is + // what a request with no member segment already means, so emitting MemberID(0) would + // add two bytes that say nothing - and every address of the form "tag:TYPE:n" used to + // be sent without one. + if (offset > 0) { + elements.add(new MemberElement(offset)); } } return Collections.unmodifiableList(elements); @@ -118,13 +186,11 @@ private static List decomposePath(String tag) { @Override public String getAddressString() { // Mirrors the format ADDRESS_PATTERN accepts, so of(getAddressString()) round-trips: - // tag[:dataType[:elementNb]] - StringBuilder sb = new StringBuilder(tag); + // tag[range][:dataType] + StringBuilder sb = new StringBuilder(ArrayNotationParser.addressPart(tag)); + sb.append(ArrayNotationParser.render(selection)); if (type != null) { sb.append(':').append(type.name()); - if (elementNb > 1) { - sb.append(':').append(elementNb); - } } return sb.toString(); } @@ -134,17 +200,37 @@ public PlcValueType getPlcValueType() { return PlcValueType.valueOf(type.name()); } + /** + * The shape of the value the caller receives, so a consumer can tell a scalar from a list + * without knowing the protocol: empty for a scalar, one entry per dimension for an array. + * A bare index selects one element and so reports empty; a range reports its dimensions even + * when it spans a single element. + * + *

Not the same as what the driver fetches - reading one element of an array still walks a + * member path, which {@link #getPathElements()} describes. + */ @Override public List getArrayInfo() { - return PlcTag.super.getArrayInfo(); + return scalarSelection ? Collections.emptyList() : selection; } public CIPDataTypeCode getType() { return type; } + /** + * How many elements the request asks the device for. Derived from the selection; a tag that + * selects nothing explicitly reads a single element. + */ public int getElementNb() { - return elementNb; + // Computed as a long: the product of several dimensions overflows an int long before it + // reaches the wire, and the count is carried in 16 bits there - see the constructor, which + // refuses a selection that cannot be encoded. + long elements = 1; + for (ArrayInfo dimension : selection) { + elements *= dimension.getSize(); + } + return (int) Math.max(elements, 1); } public String getTag() { @@ -166,21 +252,21 @@ public static boolean matches(String tagQuery) { public static EipTag of(String tagString) { Matcher matcher = ADDRESS_PATTERN.matcher(tagString); - if (matcher.matches()) { - String tag = matcher.group(GROUP_NAME_TAG); - int nb = 1; - CIPDataTypeCode type; - if (!matcher.group(GROUP_NAME_GROUP_NAME_ELEMENTS).isEmpty()) { - nb = Integer.parseInt(matcher.group(GROUP_NAME_GROUP_NAME_ELEMENTS)); - } - if (!matcher.group(GROUP_NAME_TYPE).isEmpty()) { - type = CIPDataTypeCode.valueOf(matcher.group(GROUP_NAME_TYPE)); - } else { - type = CIPDataTypeCode.DINT; - } - return new EipTag(tag, type, nb); + if (!matcher.matches()) { + return null; } - return null; + String tag = matcher.group(GROUP_NAME_TAG); + String arrayExpression = matcher.group(GROUP_NAME_ARRAY); + String typeString = matcher.group(GROUP_NAME_TYPE); + + CIPDataTypeCode type = typeString == null || typeString.isEmpty() + ? CIPDataTypeCode.DINT + : CIPDataTypeCode.valueOf(typeString); + List arrayInfo = arrayExpression == null + ? Collections.emptyList() + : ArrayNotationParser.parse(arrayExpression, tagString, CONSTRAINTS); + + return new EipTag(tag + (arrayExpression == null ? "" : arrayExpression), type, arrayInfo); } @Override @@ -193,7 +279,7 @@ public void serialize(WriteBuffer writeBuffer) throws BufferException { writeBuffer.writeString(type.name().getBytes(StandardCharsets.UTF_8).length * 8, type.name(), WithOption.WithName("type"), WithOption.WithEncoding("UTF8")); } - writeBuffer.writeUnsignedInt(16, elementNb, WithOption.WithName("elementNb")); + writeBuffer.writeUnsignedInt(16, getElementNb(), WithOption.WithName("elementNb")); writeBuffer.popContext(WithOption.WithName(getClass().getSimpleName())); } diff --git a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipArrayReadTest.java b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipArrayReadTest.java index beaab91101d..ad128a8035d 100644 --- a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipArrayReadTest.java +++ b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipArrayReadTest.java @@ -40,7 +40,7 @@ class EipArrayReadTest { @Test void arrayOfDintsIsFullyDecoded() { - EipTag tag = EipTag.of("%N40[0]:DINT:8"); + EipTag tag = EipTag.of("%N40[0..7]:DINT"); assertNotNull(tag); assertEquals(8, tag.getElementNb()); @@ -68,7 +68,7 @@ void scalarStillDecodesToAScalar() { */ @Test void shortReplyIsReportedInsteadOfThrowing() { - EipTag tag = EipTag.of("%N40[0]:DINT:8"); + EipTag tag = EipTag.of("%N40[0..7]:DINT"); // Only one element's worth of data for an 8-element tag. assertNull(EipTcpConnection.parsePlcValue(tag, dints(1), CIPDataTypeCode.DINT)); @@ -101,7 +101,7 @@ void shortStringReplyIsReportedInsteadOfThrowing() { @Test void arrayOfIntsIsFullyDecoded() { - EipTag tag = EipTag.of("%N40[0]:INT:4"); + EipTag tag = EipTag.of("%N40[0..3]:INT"); ByteBuffer buffer = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN); for (short s : new short[]{10, 20, 30, 40}) { diff --git a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipBitStringTypeTest.java b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipBitStringTypeTest.java index 9fd917e8d4d..d593b9011d2 100644 --- a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipBitStringTypeTest.java +++ b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipBitStringTypeTest.java @@ -105,7 +105,7 @@ void lwordWithHighBitSetDoesNotThrow() { @Test void arrayOfDwordsIsFullyDecoded() { - EipTag tag = EipTag.of("%N40[0]:DWORD:3"); + EipTag tag = EipTag.of("%N40[0..2]:DWORD"); assertEquals(3, tag.getElementNb()); byte[] raw = bytes(12, b -> { @@ -124,7 +124,7 @@ void arrayOfDwordsIsFullyDecoded() { @Test void arrayOfLwordsIsFullyDecoded() { - EipTag tag = EipTag.of("%N40[0]:LWORD:2"); + EipTag tag = EipTag.of("%N40[0..1]:LWORD"); byte[] raw = bytes(16, b -> { b.putLong(1L); @@ -140,13 +140,13 @@ void arrayOfLwordsIsFullyDecoded() { @Test void arrayOfBytesAndWordsIsFullyDecoded() { PlcValue bytesValue = EipTcpConnection.parsePlcValue( - EipTag.of("%N40[0]:BYTE:3"), new byte[]{0x01, (byte) 0x80, (byte) 0xFF}, CIPDataTypeCode.BYTE); + EipTag.of("%N40[0..2]:BYTE"), new byte[]{0x01, (byte) 0x80, (byte) 0xFF}, CIPDataTypeCode.BYTE); assertEquals(3, bytesValue.getLength()); assertEquals(128L, bytesValue.getIndex(1).getLong()); assertEquals(255L, bytesValue.getIndex(2).getLong()); PlcValue wordsValue = EipTcpConnection.parsePlcValue( - EipTag.of("%N40[0]:WORD:2"), + EipTag.of("%N40[0..1]:WORD"), bytes(4, b -> { b.putShort((short) 0x8000); b.putShort((short) 0xFFFF); @@ -164,9 +164,9 @@ void arrayOfBytesAndWordsIsFullyDecoded() { @Test void shortReplyIsReportedInsteadOfThrowing() { assertNull(EipTcpConnection.parsePlcValue( - EipTag.of("%N40[0]:DWORD:8"), bytes(4, b -> b.putInt(1)), CIPDataTypeCode.DWORD)); + EipTag.of("%N40[0..7]:DWORD"), bytes(4, b -> b.putInt(1)), CIPDataTypeCode.DWORD)); assertNull(EipTcpConnection.parsePlcValue( - EipTag.of("%N40[0]:LWORD:4"), bytes(8, b -> b.putLong(1)), CIPDataTypeCode.LWORD)); + EipTag.of("%N40[0..3]:LWORD"), bytes(8, b -> b.putLong(1)), CIPDataTypeCode.LWORD)); } // --- writes --- diff --git a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipDecodedShapeTest.java b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipDecodedShapeTest.java new file mode 100644 index 00000000000..bb1adb39622 --- /dev/null +++ b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipDecodedShapeTest.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.eip.base; + +import org.apache.plc4x.java.api.value.PlcValue; +import org.apache.plc4x.java.eip.base.tag.EipTag; +import org.apache.plc4x.java.eip.readwrite.CIPDataTypeCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * What the caller receives has to match what {@code getArrayInfo()} promises. + * + *

The decoder decided from the element count, so a one-element range came back as a scalar + * while the tag reported it as an array - a consumer reading the tag to decide how to render the + * value was told one thing and handed another.

+ */ +class EipDecodedShapeTest { + + private static final byte[] ONE_DINT = {0x2A, 0x00, 0x00, 0x00}; + + @Test + @DisplayName("a bare index yields a scalar") + void aBareIndexIsAScalar() { + EipTag tag = EipTag.of("%rate[4]:DINT"); + assertTrue(tag.getArrayInfo().isEmpty(), "the tag says scalar"); + + PlcValue value = EipTcpConnection.parsePlcValue(tag, ONE_DINT, CIPDataTypeCode.DINT); + assertFalse(value.isList(), "and so is the value"); + assertEquals(42, value.getInt()); + } + + @Test + @DisplayName("a one-element range yields a list of one") + void aOneElementRangeIsAList() { + EipTag tag = EipTag.of("%rate[4..4]:DINT"); + assertFalse(tag.getArrayInfo().isEmpty(), "the tag says array"); + + PlcValue value = EipTcpConnection.parsePlcValue(tag, ONE_DINT, CIPDataTypeCode.DINT); + assertTrue(value.isList(), "and so is the value - this is what the count could not express"); + assertEquals(1, value.getList().size()); + assertEquals(42, value.getList().get(0).getInt()); + } + + @Test + @DisplayName("the value's shape always follows the tag's") + void theShapesAgree() { + for (String address : new String[]{"%rate:DINT", "%rate[4]:DINT", "%rate[4..4]:DINT"}) { + EipTag tag = EipTag.of(address); + PlcValue value = EipTcpConnection.parsePlcValue(tag, ONE_DINT, CIPDataTypeCode.DINT); + assertEquals(!tag.getArrayInfo().isEmpty(), value.isList(), address); + } + } + + @Test + @DisplayName("a count the request cannot carry is refused") + void refusesACountTheRequestCannotCarry() { + // The CIP element count is 16 bits: 65536 elements narrow to zero on the wire, so the + // device would be asked for nothing at all. + assertThrows(org.apache.plc4x.java.api.exceptions.PlcInvalidTagException.class, + () -> EipTag.of("%arr[0..65535]:DINT")); + + // One below the limit is asked for in full. + assertEquals(65535, EipTag.of("%arr[0..65534]:DINT").getElementNb()); + } +} diff --git a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipDockerIT.java b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipDockerIT.java index 02af9f03660..cf19d64f2a1 100644 --- a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipDockerIT.java +++ b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipDockerIT.java @@ -255,7 +255,7 @@ void readMultipleArrayElements() throws Exception { // Read all four back with a single array read. PlcReadRequest readRequest = connection.readRequestBuilder() - .addTagAddress("arr", "hurz_DINT_ARR[0]:DINT:4") + .addTagAddress("arr", "hurz_DINT_ARR[0..3]:DINT") .build(); PlcReadResponse readResponse = readRequest.execute().get(); diff --git a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipUnsignedIntegerTypeTest.java b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipUnsignedIntegerTypeTest.java index 70835d10edd..7c88cad6411 100644 --- a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipUnsignedIntegerTypeTest.java +++ b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/EipUnsignedIntegerTypeTest.java @@ -99,7 +99,7 @@ void ulintAboveLongMaxDoesNotThrow() { @Test void arrayOfUdintsIsFullyDecoded() { - EipTag tag = EipTag.of("%N40[0]:UDINT:3"); + EipTag tag = EipTag.of("%N40[0..2]:UDINT"); assertEquals(3, tag.getElementNb()); byte[] raw = bytes(12, b -> { @@ -123,7 +123,7 @@ void arrayOfUlintsIsFullyDecoded() { b.putLong(0xFFFFFFFFFFFFFFFFL); }); PlcValue value = EipTcpConnection.parsePlcValue( - EipTag.of("%N40[0]:ULINT:2"), raw, CIPDataTypeCode.ULINT); + EipTag.of("%N40[0..1]:ULINT"), raw, CIPDataTypeCode.ULINT); assertEquals(2, value.getLength()); assertEquals(BigInteger.ONE, value.getIndex(0).getBigInteger()); @@ -133,13 +133,13 @@ void arrayOfUlintsIsFullyDecoded() { @Test void arrayOfUsintsAndUintsIsFullyDecoded() { PlcValue usints = EipTcpConnection.parsePlcValue( - EipTag.of("%N40[0]:USINT:3"), new byte[]{0x01, (byte) 0x80, (byte) 0xFF}, CIPDataTypeCode.USINT); + EipTag.of("%N40[0..2]:USINT"), new byte[]{0x01, (byte) 0x80, (byte) 0xFF}, CIPDataTypeCode.USINT); assertEquals(3, usints.getLength()); assertEquals(128L, usints.getIndex(1).getLong()); assertEquals(255L, usints.getIndex(2).getLong()); PlcValue uints = EipTcpConnection.parsePlcValue( - EipTag.of("%N40[0]:UINT:2"), + EipTag.of("%N40[0..1]:UINT"), bytes(4, b -> { b.putShort((short) 0x8000); b.putShort((short) 0xFFFF); @@ -153,9 +153,9 @@ void arrayOfUsintsAndUintsIsFullyDecoded() { @Test void shortReplyIsReportedInsteadOfThrowing() { assertNull(EipTcpConnection.parsePlcValue( - EipTag.of("%N40[0]:UDINT:8"), bytes(4, b -> b.putInt(1)), CIPDataTypeCode.UDINT)); + EipTag.of("%N40[0..7]:UDINT"), bytes(4, b -> b.putInt(1)), CIPDataTypeCode.UDINT)); assertNull(EipTcpConnection.parsePlcValue( - EipTag.of("%N40[0]:ULINT:4"), bytes(8, b -> b.putLong(1)), CIPDataTypeCode.ULINT)); + EipTag.of("%N40[0..3]:ULINT"), bytes(8, b -> b.putLong(1)), CIPDataTypeCode.ULINT)); } // --- writes --- diff --git a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipArrayParityTest.java b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipArrayParityTest.java new file mode 100644 index 00000000000..89c0a4b2511 --- /dev/null +++ b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipArrayParityTest.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.eip.base.tag; + +import org.apache.plc4x.java.api.model.ArrayInfo; +import org.apache.plc4x.java.eip.base.tag.EipTag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The same selection means the same thing here as on every other driver (SC-001). + * + *

Each driver asserts this against its own address syntax; the assertions are deliberately + * identical, because that sameness is the success criterion. + */ +class EipArrayParityTest { + + @Test + void aRangeOfEightStartingAtZero() { + List dimensions = EipTag.of("myArray[0..7]:DINT").getArrayInfo(); + + assertEquals(1, dimensions.size(), "one dimension"); + assertEquals(8, dimensions.get(0).getSize(), "eight elements"); + assertEquals(0, dimensions.get(0).getLowerBound() - dimensions.get(0).getBase(), + "starting at offset zero"); + assertTrue(dimensions.get(0).isRange(), "written as a range"); + } + + @Test + void aBareIndexIsAScalar() { + assertTrue(EipTag.of("myArray[4]:DINT").getArrayInfo().isEmpty()); + } + + @Test + void anOmittedSelectionIsAScalar() { + assertTrue(EipTag.of("myArray:DINT").getArrayInfo().isEmpty()); + } +} diff --git a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipLegacyAddressTest.java b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipLegacyAddressTest.java new file mode 100644 index 00000000000..b07e2bd21c8 --- /dev/null +++ b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipLegacyAddressTest.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.eip.base.tag; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Addresses written before the array notation was unified must not parse. + * + *

EIP's brackets already meant an index, so what changed here is the separate ':elementNb' + * suffix that used to carry the count. An address still using it does not parse, rather than + * quietly reading one element where it used to read several. + */ +class EipLegacyAddressTest { + + @Test + void theElementCountSuffixNoLongerParses() { + assertNull(EipTag.of("myArray[0]:DINT:8")); + assertNull(EipTag.of("myTag:DINT:4")); + assertNull(EipTag.of("myTag:4")); + } + + @Test + void theReplacementFormParses() { + assertEquals(8, EipTag.of("myArray[0..7]:DINT").getElementNb()); + assertEquals(4, EipTag.of("myTag[0..3]:DINT").getElementNb()); + } + + /** matches() and of() agree, so a tag handler sees the same answer either way. */ + @Test + void matchesAgreesWithOf() { + assertFalse(EipTag.matches("myArray[0]:DINT:8")); + assertTrue(EipTag.matches("myArray[0..7]:DINT")); + } +} diff --git a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagCoverageTest.java b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagCoverageTest.java index 3f432b12a4c..15a7aace1dd 100644 --- a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagCoverageTest.java +++ b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagCoverageTest.java @@ -74,16 +74,16 @@ void typeAndElementNbComeFromTheConstructor() { @Test void ofParsesTagWithTypeAndElements() { - EipTag tag = EipTag.of("MyVar:INT:5"); + EipTag tag = EipTag.of("MyVar[0..4]:INT"); assertThat(tag).isNotNull(); - assertThat(tag.getTag()).isEqualTo("MyVar"); + assertThat(tag.getTag()).isEqualTo("MyVar[0..4]"); assertThat(tag.getType()).isEqualTo(CIPDataTypeCode.INT); assertThat(tag.getElementNb()).isEqualTo(5); } @Test void ofDefaultsDataTypeToDintWhenMissing() { - EipTag tag = EipTag.of("%A0:2"); + EipTag tag = EipTag.of("%A0[0..1]"); assertThat(tag).isNotNull(); assertThat(tag.getType()).isEqualTo(CIPDataTypeCode.DINT); assertThat(tag.getElementNb()).isEqualTo(2); @@ -93,7 +93,7 @@ void ofDefaultsDataTypeToDintWhenMissing() { void ofWithZeroElementsReadsOneElement() { // An explicit count of zero used to survive into the tag; a request for zero elements // is meaningless, so it is normalised to one like any other count below one. - EipTag tag = EipTag.of("%A0:INT:0"); + EipTag tag = EipTag.of("%A0:INT"); assertThat(tag).isNotNull(); assertThat(tag.getElementNb()).isEqualTo(1); assertThat(tag.getType()).isEqualTo(CIPDataTypeCode.INT); @@ -101,7 +101,7 @@ void ofWithZeroElementsReadsOneElement() { @Test void matchesAgreesWithOf() { - assertThat(EipTag.matches("%A0:2")).isTrue(); + assertThat(EipTag.matches("%A0[0..1]")).isTrue(); // The regex is permissive — the unmatched-input branch in `of` // returns null; we don't need a separate negative test for `matches` // beyond the positive case above. @@ -113,10 +113,10 @@ void getAddressStringRoundTrips() { // so the bare constructor renders just the tag name. assertThat(new EipTag("%A0").getAddressString()).isEqualTo("%A0"); assertThat(new EipTag("%A0", CIPDataTypeCode.DINT).getAddressString()).isEqualTo("%A0:DINT"); - assertThat(new EipTag("%A0", CIPDataTypeCode.DINT, 8).getAddressString()).isEqualTo("%A0:DINT:8"); + assertThat(new EipTag("%A0", CIPDataTypeCode.DINT, 8).getAddressString()).isEqualTo("%A0[0..7]:DINT"); // Whatever it renders has to parse back into an equivalent tag. - EipTag original = EipTag.of("%A0:DINT:8"); + EipTag original = EipTag.of("%A0[0..7]:DINT"); EipTag reparsed = EipTag.of(original.getAddressString()); assertThat(reparsed.getTag()).isEqualTo(original.getTag()); assertThat(reparsed.getType()).isEqualTo(original.getType()); diff --git a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagHandlerTest.java b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagHandlerTest.java index 41183f56dae..7e32c8b6f40 100644 --- a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagHandlerTest.java +++ b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagHandlerTest.java @@ -29,7 +29,7 @@ class EipTagHandlerTest { @Test void parsesValidTag() { - assertThat(handler.parseTag("%A0:2")).isInstanceOf(EipTag.class); + assertThat(handler.parseTag("%A0[0..1]")).isInstanceOf(EipTag.class); } @Test diff --git a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagPathTest.java b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagPathTest.java index 8663e50e579..0db1c27bee9 100644 --- a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagPathTest.java +++ b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagPathTest.java @@ -95,10 +95,10 @@ void indexBeforeAFurtherMemberKeepsItsPosition() { new EipTag("a[2].b", CIPDataTypeCode.DINT).getPathElements()); } - /** Empty brackets carry no member, matching what the address pattern already accepted. */ + /** Empty brackets name no element and are rejected rather than quietly ignored. */ @Test - void emptyBracketsContributeNoMember() { - assertEquals(List.of(new SymbolElement("a")), path("a[]:DINT")); + void emptyBracketsAreRejected() { + assertNull(EipTag.of("a[]:DINT")); } @Test @@ -136,8 +136,8 @@ void tagIsImmutable() { void elementCountIsNeverBelowOne() { assertEquals(1, new EipTag("a", CIPDataTypeCode.DINT, 0).getElementNb()); assertEquals(1, new EipTag("a", CIPDataTypeCode.DINT, -5).getElementNb()); - assertEquals(1, EipTag.of("a:DINT:0").getElementNb()); - assertEquals(8, EipTag.of("a:DINT:8").getElementNb()); + assertEquals(1, EipTag.of("a:DINT").getElementNb()); + assertEquals(8, EipTag.of("a[0..7]:DINT").getElementNb()); } /** @@ -156,6 +156,28 @@ void indexBeyondAMemberIdIsRejected() { path("a[255]:DINT")); } + // --- what a consumer sees --- + + /** + * getArrayInfo() tells a consumer whether it received a scalar or a list. A bare index + * selects one element, so it reports empty; a range reports its dimensions even when it + * spans one element. Reading that one element still walks a member path, which is a + * separate concern. + */ + @Test + void arrayInfoDescribesTheValueNotTheFetch() { + assertTrue(EipTag.of("myArray[4]:DINT").getArrayInfo().isEmpty(), "a bare index is a scalar"); + assertTrue(EipTag.of("myTag:DINT").getArrayInfo().isEmpty(), "no selection is a scalar"); + + assertEquals(1, EipTag.of("myArray[4..4]:DINT").getArrayInfo().size(), "a range is an array"); + assertEquals(8, EipTag.of("myArray[0..7]:DINT").getArrayInfo().get(0).getSize()); + + // The single element still needs its member segment on the wire. + assertEquals( + List.of(new SymbolElement("myArray"), new MemberElement((short) 4)), + EipTag.of("myArray[4]:DINT").getPathElements()); + } + private static List path(String address) { EipTag tag = EipTag.of(address); assertNotNull(tag, address); diff --git a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagTest.java b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagTest.java index 982d23de14c..2159d080e69 100644 --- a/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagTest.java +++ b/plc4j/drivers/eip/src/test/java/org/apache/plc4x/java/eip/base/tag/EipTagTest.java @@ -27,10 +27,10 @@ public class EipTagTest { @Test public void testTagParse() { - EipTag eipTag = EipTag.of("%A0:2"); + EipTag eipTag = EipTag.of("%A0[0..1]"); Assertions.assertNotNull(eipTag); - Assertions.assertEquals(eipTag.getTag(), "%A0"); + Assertions.assertEquals("%A0[0..1]", eipTag.getTag()); Assertions.assertEquals(eipTag.getType(), CIPDataTypeCode.DINT); Assertions.assertEquals(eipTag.getElementNb(), 2); } @@ -44,9 +44,9 @@ public void testTagParse() { public void testDocumentedAddressForms() { assertTag("myTag", "myTag", CIPDataTypeCode.DINT, 1); assertTag("myTag:REAL", "myTag", CIPDataTypeCode.REAL, 1); - assertTag("myTag:4", "myTag", CIPDataTypeCode.DINT, 4); + assertTag("myTag[0..3]", "myTag[0..3]", CIPDataTypeCode.DINT, 4); assertTag("myArray[3]:DINT", "myArray[3]", CIPDataTypeCode.DINT, 1); - assertTag("myArray[0]:DINT:4", "myArray[0]", CIPDataTypeCode.DINT, 4); + assertTag("myArray[0..3]:DINT", "myArray[0..3]", CIPDataTypeCode.DINT, 4); // The '%' prefix is optional. assertTag("%myTag:REAL", "%myTag", CIPDataTypeCode.REAL, 1); } diff --git a/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/tag/FirmataTag.java b/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/tag/FirmataTag.java index d81e26ebb84..5b619725d56 100644 --- a/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/tag/FirmataTag.java +++ b/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/tag/FirmataTag.java @@ -19,6 +19,9 @@ package org.apache.plc4x.java.firmata.tag; import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.api.model.ArrayInfo; +import org.apache.plc4x.java.spi.drivers.model.AddressConstraints; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.api.model.PlcTag; import java.util.Objects; @@ -28,7 +31,7 @@ public abstract class FirmataTag implements PlcTag { public static final Pattern ADDRESS_PATTERN = - Pattern.compile("(?

\\d{1,3})(\\[(?\\d{1,3})])?"); + Pattern.compile("(?
\\d{1,3})" + ArrayNotationParser.ARRAY_GROUP); /** * Number of pins the protocol can name at all: a pin travels the wire in eight bits. @@ -39,6 +42,12 @@ public abstract class FirmataTag implements PlcTag { private final int quantity; + /** + * Whether the address wrote the selection as a range. {@code 3[4]} and {@code 3[4..4]} both + * select one pin, and only the range is an array, so the count cannot carry this. + */ + private final boolean explicitRange; + public static FirmataTag of(String tagString) { Matcher matcher = FirmataTagAnalog.ADDRESS_PATTERN.matcher(tagString); if (matcher.matches()) { @@ -51,7 +60,30 @@ public static FirmataTag of(String tagString) { throw new PlcInvalidTagException("Unable to parse address: " + tagString); } + /** + * Resolves the address's array expression to the offset of the first pin and the number of + * pins. Firmata addresses carry no type, so the expression terminates the address. + * + * @return {@code {offset, quantity, rangeWritten}} - the last is 1 or 0, and is not + * derivable from the others: {@code [4]} and {@code [4..4]} both select one element + */ + protected static int[] selectionOf(Matcher matcher, String address) { + String expression = matcher.group("array"); + if (expression == null) { + return new int[]{0, 1, 0}; + } + ArrayInfo dimension = ArrayNotationParser + .parse(expression, address, AddressConstraints.SINGLE_DIMENSION).getFirst(); + return new int[]{dimension.getLowerBound() - dimension.getBase(), dimension.getSize(), + dimension.isRange() ? 1 : 0}; + } + protected FirmataTag(int address, Integer quantity) { + this(address, quantity, (quantity != null) && (quantity > 1)); + } + + protected FirmataTag(int address, Integer quantity, boolean explicitRange) { + this.explicitRange = explicitRange; this.address = address; this.quantity = quantity != null ? quantity : 1; if (this.quantity <= 0) { @@ -70,6 +102,11 @@ public int getAddress() { return address; } + /** Whether the address wrote a range, as opposed to selecting a single pin. */ + public boolean isExplicitRange() { + return explicitRange; + } + public int getNumberOfElements() { return quantity; } @@ -79,11 +116,10 @@ public boolean equals(Object o) { if (this == o) { return true; } - if (!(o instanceof FirmataTag)) { - return false; + if (o instanceof FirmataTag that) { + return address == that.address; } - FirmataTag that = (FirmataTag) o; - return address == that.address; + return false; } @Override diff --git a/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/tag/FirmataTagAnalog.java b/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/tag/FirmataTagAnalog.java index 964a37b5cb4..dc15a61282a 100644 --- a/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/tag/FirmataTagAnalog.java +++ b/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/tag/FirmataTagAnalog.java @@ -21,6 +21,7 @@ import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; import org.apache.plc4x.java.api.model.ArrayInfo; import org.apache.plc4x.java.api.types.PlcValueType; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo; import java.util.Collections; @@ -36,13 +37,15 @@ public FirmataTagAnalog(int address, Integer quantity) { super(address, quantity); } + public FirmataTagAnalog(int address, Integer quantity, boolean explicitRange) { + super(address, quantity, explicitRange); + + } + @Override public String getAddressString() { - String address = "analog:" + getAddress(); - if(getNumberOfElements() != 1) { - address += "[" + getNumberOfElements() + "]"; - } - return address; + // The selection terminates the address; Firmata carries no type suffix. + return "analog:" + getAddress() + ArrayNotationParser.render(getArrayInfo()); } @Override @@ -52,8 +55,9 @@ public PlcValueType getPlcValueType() { @Override public List getArrayInfo() { - if(getNumberOfElements() != 1) { - return Collections.singletonList(new DefaultArrayInfo(0, getNumberOfElements())); + // A range is an array even when it spans one pin; the count cannot say which was written. + if (isExplicitRange()) { + return Collections.singletonList(new DefaultArrayInfo(0, getNumberOfElements() - 1, 0, true)); } return Collections.emptyList(); } @@ -64,9 +68,10 @@ public static FirmataTagAnalog of(String addressString) throws PlcInvalidTagExce } int address = Integer.parseInt(matcher.group("address")); - String quantityString = matcher.group("quantity"); - Integer quantity = quantityString != null ? Integer.valueOf(quantityString) : null; - return new FirmataTagAnalog(address, quantity); + int[] selection = selectionOf(matcher, addressString); + address += selection[0]; + Integer quantity = selection[1]; + return new FirmataTagAnalog(address, quantity, selection[2] == 1); } } diff --git a/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/tag/FirmataTagDigital.java b/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/tag/FirmataTagDigital.java index 6cb94fc2c04..dc03422a2f8 100644 --- a/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/tag/FirmataTagDigital.java +++ b/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/tag/FirmataTagDigital.java @@ -22,6 +22,7 @@ import org.apache.plc4x.java.api.model.ArrayInfo; import org.apache.plc4x.java.api.types.PlcValueType; import org.apache.plc4x.java.firmata.readwrite.PinMode; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo; import java.util.BitSet; @@ -39,10 +40,14 @@ public class FirmataTagDigital extends FirmataTag { protected final PinMode pinMode; public FirmataTagDigital(int address, Integer quantity, PinMode pinMode) { - super(address, quantity); + this(address, quantity, pinMode, (quantity != null) && (quantity > 1)); + } + + public FirmataTagDigital(int address, Integer quantity, PinMode pinMode, boolean explicitRange) { + super(address, quantity, explicitRange); // Translate the address into a bit-set. bitSet = new BitSet(); - for(int i = getAddress(); i < getAddress() + getNumberOfElements(); i++) { + for (int i = getAddress(); i < getAddress() + getNumberOfElements(); i++) { bitSet.set(i, true); } this.pinMode = pinMode; @@ -50,11 +55,8 @@ public FirmataTagDigital(int address, Integer quantity, PinMode pinMode) { @Override public String getAddressString() { - String address = "digital:" + getAddress(); - if(getNumberOfElements() != 1) { - address += "[" + getNumberOfElements() + "]"; - } - return address; + // The selection terminates the address; Firmata carries no type suffix. + return "digital:" + getAddress() + ArrayNotationParser.render(getArrayInfo()); } @Override @@ -64,8 +66,9 @@ public PlcValueType getPlcValueType() { @Override public List getArrayInfo() { - if(getNumberOfElements() != 1) { - return Collections.singletonList(new DefaultArrayInfo(0, getNumberOfElements())); + // A range is an array even when it spans one pin; the count cannot say which was written. + if (isExplicitRange()) { + return Collections.singletonList(new DefaultArrayInfo(0, getNumberOfElements() - 1, 0, true)); } return Collections.emptyList(); } @@ -85,12 +88,13 @@ public static FirmataTagDigital of(String addressString) { } int address = Integer.parseInt(matcher.group("address")); - String quantityString = matcher.group("quantity"); - Integer quantity = quantityString != null ? Integer.valueOf(quantityString) : null; + int[] selection = selectionOf(matcher, addressString); + address += selection[0]; + Integer quantity = selection[1]; PinMode pinMode = ("PULLUP".equals(matcher.group("mode"))) ? PinMode.PinModePullup : null; - return new FirmataTagDigital(address, quantity, pinMode); + return new FirmataTagDigital(address, quantity, pinMode, selection[2] == 1); } } diff --git a/plc4j/drivers/firmata/src/test/java/org/apache/plc4x/java/firmata/tag/FirmataTagPinSpanTest.java b/plc4j/drivers/firmata/src/test/java/org/apache/plc4x/java/firmata/tag/FirmataTagPinSpanTest.java index 3e4764e06b6..70af3355b75 100644 --- a/plc4j/drivers/firmata/src/test/java/org/apache/plc4x/java/firmata/tag/FirmataTagPinSpanTest.java +++ b/plc4j/drivers/firmata/src/test/java/org/apache/plc4x/java/firmata/tag/FirmataTagPinSpanTest.java @@ -47,12 +47,12 @@ void anAddressTooWideToBeANumberIsAnInvalidTagNotANumberFormatError() { @Test void aSpanRunningPastTheLastPinIsRejected() { - assertThrows(PlcInvalidTagException.class, () -> FirmataTag.of("digital:250[100]")); + assertThrows(PlcInvalidTagException.class, () -> FirmataTag.of("digital:250[0..99]")); } @Test void theWholePinSpaceIsStillAllowed() { - FirmataTagDigital tag = FirmataTagDigital.of("digital:0[256]"); + FirmataTagDigital tag = FirmataTagDigital.of("digital:0[0..255]"); assertEquals(256, tag.getNumberOfElements()); assertEquals(256, tag.getBitSet().cardinality()); } diff --git a/plc4j/drivers/firmata/src/test/java/org/apache/plc4x/java/firmata/tag/FirmataTagTest.java b/plc4j/drivers/firmata/src/test/java/org/apache/plc4x/java/firmata/tag/FirmataTagTest.java index b15b15ad987..4cf2d103b8c 100644 --- a/plc4j/drivers/firmata/src/test/java/org/apache/plc4x/java/firmata/tag/FirmataTagTest.java +++ b/plc4j/drivers/firmata/src/test/java/org/apache/plc4x/java/firmata/tag/FirmataTagTest.java @@ -65,11 +65,11 @@ void digitalDefaults() { @Test void digitalWithRangeAndPullup() { - FirmataTagDigital tag = FirmataTagDigital.of("digital:8[4]:PULLUP"); + FirmataTagDigital tag = FirmataTagDigital.of("digital:8[0..3]:PULLUP"); assertEquals(8, tag.getAddress()); assertEquals(4, tag.getNumberOfElements()); assertEquals(PinMode.PinModePullup, tag.getPinMode()); - assertEquals("digital:8[4]", tag.getAddressString()); + assertEquals("digital:8[0..3]", tag.getAddressString()); assertFalse(tag.getArrayInfo().isEmpty()); // BitSet should mark pins 8..11. for (int pin = 8; pin < 12; pin++) { @@ -96,10 +96,10 @@ void analogDefaults() { @Test void analogWithRange() { - FirmataTagAnalog tag = FirmataTagAnalog.of("analog:0[3]"); + FirmataTagAnalog tag = FirmataTagAnalog.of("analog:0[0..2]"); assertEquals(0, tag.getAddress()); assertEquals(3, tag.getNumberOfElements()); - assertEquals("analog:0[3]", tag.getAddressString()); + assertEquals("analog:0[0..2]", tag.getAddressString()); assertFalse(tag.getArrayInfo().isEmpty()); } @@ -111,7 +111,7 @@ void analogRejectsMalformedAddress() { @Test void equalityAndHashAreAddressBased() { FirmataTagDigital a = FirmataTagDigital.of("digital:5"); - FirmataTagDigital b = FirmataTagDigital.of("digital:5[3]"); + FirmataTagDigital b = FirmataTagDigital.of("digital:5[0..2]"); FirmataTagDigital c = FirmataTagDigital.of("digital:6"); // FirmataTag.equals compares by address only, so a and b match. assertEquals(a, b); diff --git a/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpBrowse.java b/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpBrowse.java index a36c6f6fb37..9fde14f810a 100644 --- a/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpBrowse.java +++ b/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpBrowse.java @@ -35,8 +35,8 @@ public class ManualKnxNetIpBrowse { public static void main(String[] args) throws Exception { try (PlcConnection connection = new DefaultPlcDriverManager().getConnection( "knxnet-ip://192.168.42.28?" + - "knxproj-file-path=/Users/christoferdutz/Projects/Privat/NLNet/plc4x/plc4j/drivers/knxnetip/Stettiner-Str-13.knxproj&" + - "knxproj-password=cW171998$")) { + "knxproj-file-path=huiiiii&" + + "knxproj-password=lalala")) { // Create a browse request for all group addresses PlcBrowseResponse plcBrowseResponse = connection.browseRequestBuilder() diff --git a/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpRead.java b/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpRead.java index b599f406dad..6c921192b1d 100644 --- a/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpRead.java +++ b/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpRead.java @@ -33,8 +33,8 @@ public class ManualKnxNetIpRead { public static void main(String[] args) throws Exception { try (PlcConnection connection = new DefaultPlcDriverManager().getConnection( "knxnet-ip://192.168.42.28?" + - "knxproj-file-path=/Users/christoferdutz/Projects/Privat/NLNet/plc4x/plc4j/drivers/knxnetip/Stettiner-Str-13.knxproj&" + - "knxproj-password=cW171998$")) { + "knxproj-file-path=huiiiii&" + + "knxproj-password=lalala")) { PlcReadRequest readRequest = connection.readRequestBuilder() .addTagAddress("Lade-Leistung Batterie", "1/1/210:DPT14") // Temperature (2-byte float) diff --git a/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpSubscription.java b/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpSubscription.java index c558d74d5bc..8897cbb18f6 100644 --- a/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpSubscription.java +++ b/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpSubscription.java @@ -36,7 +36,7 @@ public class ManualKnxNetIpSubscription { // */*/101: Power Line 1[10..15] public static void main(String[] args) throws Exception { - final PlcConnection connection = new DefaultPlcDriverManager().getConnection("knxnet-ip://192.168.42.28?knxproj-file-path=/Users/christoferdutz/Projects/Privat/NLNet/plc4x/plc4j/drivers/knxnetip/Stettiner-Str-13.knxproj&knxproj-password=cW171998$"); + final PlcConnection connection = new DefaultPlcDriverManager().getConnection("knxnet-ip://192.168.42.28?knxproj-file-path=huiiiii&knxproj-password=lalala"); //final PlcConnection connection = new DefaultPlcDriverManager().getConnection("knxnet-ip:pcap:///Users/christofer.dutz/Projects/Apache/PLC4X-Documents/KNX/Recording-01.03.2020-2.pcapng?knxproj-file-path=/Users/christofer.dutz/Projects/Apache/PLC4X-Documents/KNX/Stettiner%20Str.%2013/StettinerStr-Soll-Ist-Temperatur.knxproj"); // Make sure we hang up correctly when terminating. Runtime.getRuntime().addShutdownHook(new Thread(() -> { diff --git a/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpWrite.java b/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpWrite.java index 9fd992dee75..7764e7c67c4 100644 --- a/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpWrite.java +++ b/plc4j/drivers/knxnetip/src/test/java/org/apache/plc4x/java/knxnetip/maual/ManualKnxNetIpWrite.java @@ -33,8 +33,8 @@ public class ManualKnxNetIpWrite { public static void main(String[] args) throws Exception { try (PlcConnection connection = new DefaultPlcDriverManager().getConnection( "knxnet-ip://192.168.42.28?" + - "knxproj-file-path=/Users/christoferdutz/Projects/Privat/NLNet/plc4x/plc4j/drivers/knxnetip/Stettiner-Str-13.knxproj&" + - "knxproj-password=cW171998$")) { + "knxproj-file-path=huiiiii&" + + "knxproj-password=lalala")) { PlcWriteRequest writeRequest = connection.writeRequestBuilder() .addTagAddress("Licht Büro", "3/4/0:DPT1", new PlcBOOL(true)) // Temperature (2-byte float) diff --git a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTag.java b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTag.java index 61a3438ed25..b22a56437f5 100644 --- a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTag.java +++ b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTag.java @@ -28,19 +28,24 @@ import org.apache.plc4x.java.spi.buffers.api.WithOption; import org.apache.plc4x.java.spi.buffers.api.WriteBuffer; import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException; +import org.apache.plc4x.java.spi.drivers.model.AddressConstraints; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo; import java.nio.charset.StandardCharsets; import java.util.*; +import java.util.regex.Matcher; import java.util.regex.Pattern; public abstract class ModbusTag implements PlcTag, Serializable { + /** The shared array notation, which sits between the address and the type. */ + private static final String ARRAY_EXPRESSION = ArrayNotationParser.ARRAY_GROUP; + // STRING and WSTRING carry the length of one string in parentheses, the same way the S7 driver - // spells it: "holding-register:1:STRING(20)[3]" is three 20-character strings. The quantity in - // brackets keeps meaning "how many values", as it does for every other data type. - public static final Pattern ADDRESS_PATTERN = Pattern.compile("(?
\\d{1,9})(:(?[a-zA-Z_]+)(\\((?\\d{1,5})\\))?)?(\\[(?\\d{1,5})])?"); - public static final Pattern FIXED_DIGIT_MODBUS_PATTERN = Pattern.compile("(?
\\d{4,5})?(:(?[a-zA-Z_]+)(\\((?\\d{1,5})\\))?)?(\\[(?\\d{1,5})])?"); + // spells it: "holding-register:1[0..2]:STRING(20)" is three 20-character strings. + public static final Pattern ADDRESS_PATTERN = Pattern.compile("(?
\\d{1,9})" + ARRAY_EXPRESSION + "(:(?[a-zA-Z_]+)(\\((?\\d{1,5})\\))?)?"); + public static final Pattern FIXED_DIGIT_MODBUS_PATTERN = Pattern.compile("(?
\\d{4,5})?" + ARRAY_EXPRESSION + "(:(?[a-zA-Z_]+)(\\((?\\d{1,5})\\))?)?"); public static final int PROTOCOL_ADDRESS_OFFSET = 1; @@ -48,6 +53,13 @@ public abstract class ModbusTag implements PlcTag, Serializable { private final int quantity; + /** + * Whether the address wrote the selection as a range. A one-element range is still a range - + * {@code [4]} yields a scalar and {@code [4..4]} a list of one - and the count alone cannot + * say which was written, so the parser's answer is carried here. + */ + private final boolean explicitRange; + /** The declared length of a single string; 1 for every other data type. */ private final int stringLength; @@ -55,6 +67,75 @@ public abstract class ModbusTag implements PlcTag, Serializable { private final Short unitId; private final ModbusByteOrder byteOrder; + /** + * Resolves the address's array expression to the offset from the base address, the number of + * elements, and whether a range was written. An absent expression selects one element at the + * address itself. + * + *

The third value is not derivable from the other two: {@code [4]} and {@code [4..4]} both + * select one element, and only the range is an array.

+ * + * @return {@code {offset, quantity, rangeWritten}}, the last being 1 or 0 + */ + protected static int[] selectionOf(Matcher matcher, String addressString) { + String expression = matcher.group("array"); + if (expression == null) { + return new int[]{0, 1, 0}; + } + ArrayInfo dimension = ArrayNotationParser + .parse(expression, addressString, AddressConstraints.SINGLE_DIMENSION).getFirst(); + return new int[]{dimension.getLowerBound() - dimension.getBase(), dimension.getSize(), + dimension.isRange() ? 1 : 0}; + } + + /** + * How many bytes one element of the given type occupies on the wire: a string its declared + * length, anything else its data type's size. + */ + protected static int bytesPerElement(ModbusDataType dataType, Integer stringLength) { + return ((stringLength != null) ? stringLength : 1) * dataType.getDataTypeSize(); + } + + /** + * How far into the register area a selection starts, in registers. + * + *

A selection offset counts elements, but a register-area address counts registers, so the + * two have to be reconciled before the offset is applied. They coincide for a one-register + * type, which is why every example using INT looked right while {@code [4]:DINT} silently read + * four registers short of its target.

+ * + *

The conversion goes through the total byte offset rather than rounding each element up + * on its own. Elements narrower than a register are packed - two {@code CHAR}s share one + * register, which is what {@link #getLengthWords()} reports for the length - so rounding per + * element would place the start where nothing was written. An offset that does not land on a + * register boundary cannot be addressed by a Modbus read at all, and is rejected here rather + * than silently moved to the register before or after it.

+ */ + protected static int registerOffsetOf(int elementOffset, ModbusDataType dataType, + Integer stringLength, String addressString) { + long bytes = (long) elementOffset * bytesPerElement(dataType, stringLength); + if ((bytes % 2) != 0) { + throw new IllegalArgumentException("Selection starts " + bytes + " bytes into the " + + "address, which is not a register boundary. A Modbus read starts at a register, " + + "so an odd byte offset cannot be addressed. Was " + addressString); + } + return (int) (bytes / 2); + } + + /** + * How many registers a selection of the given number of elements occupies. + * + *

This is the count the wire carries, and the one the address-space and per-request limits + * have to be checked against - 63 {@code DINT}s are 126 registers and do not fit into a + * request that carries 125, however few elements that is.

+ */ + protected static int registerCountOf(int quantity, ModbusDataType dataType, Integer stringLength) { + long bytes = (long) quantity * bytesPerElement(dataType, stringLength); + // Round the total up, and never to nothing: a value narrower than a register still + // occupies a whole one, and every request has to ask for something. + return (int) Math.max(1, (bytes + 1) / 2); + } + public static ModbusTag of(String addressString) { if (ModbusTagCoil.matches(addressString)) { return ModbusTagCoil.of(addressString); @@ -71,18 +152,18 @@ public static ModbusTag of(String addressString) { if (ModbusTagExtendedRegister.matches(addressString)) { return ModbusTagExtendedRegister.of(addressString); } - throw new PlcInvalidTagException("Unable to parse address: " + addressString); + throw ArrayNotationParser.invalidAddress(addressString, + "{area}:{address}[selection]:{TYPE} - for example holding-register:1[0..3]:INT"); } @Override public String getAddressString() { + // The selection sits between the address and the type, in the shared notation. String address = String.format("%s%05d", getAddressStringPrefix(), getLogicalAddress()); - if(getDataType() != null) { + address += ArrayNotationParser.render(getArrayInfo()); + if (getDataType() != null) { address += ":" + getDataType().name(); } - if(!getArrayInfo().isEmpty()) { - address += "[" + (getArrayInfo().get(0).getUpperBound() + 1) + "]"; - } return address; } @@ -104,6 +185,12 @@ protected ModbusTag(int address, Integer quantity, ModbusDataType dataType, Map< protected ModbusTag(int address, Integer quantity, Integer stringLength, ModbusDataType dataType, Map config) { + this(address, quantity, stringLength, dataType, config, (quantity != null) && (quantity > 1)); + } + + protected ModbusTag(int address, Integer quantity, Integer stringLength, ModbusDataType dataType, + Map config, boolean explicitRange) { + this.explicitRange = explicitRange; this.address = address; if (getLogicalAddress() <= 0) { throw new IllegalArgumentException("address must be greater than zero. Was " + getLogicalAddress()); @@ -178,7 +265,11 @@ public int getLengthBytes() { } public int getLengthWords() { - return (int) ((quantity * stringLength * (float) dataType.getDataTypeSize()) / 2.0f); + // Rounded up, and never to zero: a single CHAR is one byte and still occupies a whole + // register, where truncating the halved byte count asked for none at all. This is the + // same arithmetic registerOffsetOf() applies to the start, so the offset and the length + // cannot disagree. + return registerCountOf(quantity, dataType, stringLength); } public ModbusDataType getDataType() { @@ -192,26 +283,34 @@ public PlcValueType getPlcValueType() { @Override public List getArrayInfo() { - if(quantity != 1) { - return Collections.singletonList(new DefaultArrayInfo(0, quantity - 1)); + // A range is an array even when it spans one element, so the flag decides the shape and + // the count only sizes it. Deriving the shape from the count alone reported [4..4] as a + // scalar, contradicting the notation's own rule. + if (explicitRange) { + return Collections.singletonList(new DefaultArrayInfo(0, quantity - 1, 0, true)); } return Collections.emptyList(); } + /** Whether the address wrote a range, as opposed to selecting a single element. */ + public boolean isExplicitRange() { + return explicitRange; + } + @Override public boolean equals(Object o) { if (this == o) { return true; } - if (!(o instanceof ModbusTag)) { - return false; + if (o instanceof ModbusTag that) { + return address == that.address && + quantity == that.quantity && + explicitRange == that.explicitRange && + dataType == that.dataType && + Objects.equals(unitId, that.unitId) && + getClass() == that.getClass(); // MUST be identical } - ModbusTag that = (ModbusTag) o; - return address == that.address && - quantity == that.quantity && - dataType == that.dataType && - unitId == that.unitId && - getClass() == that.getClass(); // MUST be identical + return false; } @Override diff --git a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagCoil.java b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagCoil.java index 4eed3baed73..bfe30684147 100644 --- a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagCoil.java +++ b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagCoil.java @@ -43,6 +43,10 @@ public ModbusTagCoil(int address, Integer quantity, Integer stringLength, Modbus super(address, quantity, stringLength, dataType, config); } + public ModbusTagCoil(int address, Integer quantity, Integer stringLength, ModbusDataType dataType, Map config, boolean explicitRange) { + super(address, quantity, stringLength, dataType, config, explicitRange); + } + protected String getAddressStringPrefix() { return ADDRESS_PREFIX; } @@ -82,8 +86,9 @@ public static ModbusTagCoil of(String addressString) { throw new IllegalArgumentException("Address must be less than or equal to " + REGISTER_MAX_ADDRESS + ". Was " + (address + PROTOCOL_ADDRESS_OFFSET)); } - String quantityString = matcher.group("quantity"); - int quantity = quantityString != null ? Integer.parseInt(quantityString) : 1; + int[] selection = selectionOf(matcher, addressString); + address += selection[0]; + int quantity = selection[1]; if ((address + quantity) > REGISTER_MAX_ADDRESS) { throw new IllegalArgumentException("Last requested address is out of range, should be between " + PROTOCOL_ADDRESS_OFFSET + " and " + REGISTER_MAX_ADDRESS + ". Was " + (address + PROTOCOL_ADDRESS_OFFSET + (quantity - 1))); } @@ -97,7 +102,7 @@ public static ModbusTagCoil of(String addressString) { String stringLengthString = matcher.group("stringLength"); Integer stringLength = (stringLengthString != null) ? Integer.parseInt(stringLengthString) : null; - return new ModbusTagCoil(address, quantity, stringLength, dataType, TagConfigParser.parse(addressString)); + return new ModbusTagCoil(address, quantity, stringLength, dataType, TagConfigParser.parse(addressString), selection[2] == 1); } } diff --git a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagDiscreteInput.java b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagDiscreteInput.java index c40a716aaf5..f4acdd5f5c5 100644 --- a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagDiscreteInput.java +++ b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagDiscreteInput.java @@ -43,6 +43,10 @@ public ModbusTagDiscreteInput(int address, Integer quantity, Integer stringLengt super(address, quantity, stringLength, dataType, config); } + public ModbusTagDiscreteInput(int address, Integer quantity, Integer stringLength, ModbusDataType dataType, Map config, boolean explicitRange) { + super(address, quantity, stringLength, dataType, config, explicitRange); + } + protected String getAddressStringPrefix() { return ADDRESS_PREFIX; } @@ -82,8 +86,9 @@ public static ModbusTagDiscreteInput of(String addressString) { throw new IllegalArgumentException("Address must be less than or equal to " + REGISTER_MAX_ADDRESS + ". Was " + (address + PROTOCOL_ADDRESS_OFFSET)); } - String quantityString = matcher.group("quantity"); - int quantity = quantityString != null ? Integer.parseInt(quantityString) : 1; + int[] selection = selectionOf(matcher, addressString); + address += selection[0]; + int quantity = selection[1]; if ((address + quantity) > REGISTER_MAX_ADDRESS) { throw new IllegalArgumentException("Last requested address is out of range, should be between " + PROTOCOL_ADDRESS_OFFSET + " and " + REGISTER_MAX_ADDRESS + ". Was " + (address + PROTOCOL_ADDRESS_OFFSET + (quantity - 1))); } @@ -97,6 +102,6 @@ public static ModbusTagDiscreteInput of(String addressString) { String stringLengthString = matcher.group("stringLength"); Integer stringLength = (stringLengthString != null) ? Integer.parseInt(stringLengthString) : null; - return new ModbusTagDiscreteInput(address, quantity, stringLength, dataType, TagConfigParser.parse(addressString)); + return new ModbusTagDiscreteInput(address, quantity, stringLength, dataType, TagConfigParser.parse(addressString), selection[2] == 1); } } diff --git a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagExtendedRegister.java b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagExtendedRegister.java index 26390f4a1bc..136edb2d151 100644 --- a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagExtendedRegister.java +++ b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagExtendedRegister.java @@ -43,6 +43,10 @@ public ModbusTagExtendedRegister(int address, Integer quantity, Integer stringLe super(address, quantity, stringLength, dataType, config); } + public ModbusTagExtendedRegister(int address, Integer quantity, Integer stringLength, ModbusDataType dataType, Map config, boolean explicitRange) { + super(address, quantity, stringLength, dataType, config, explicitRange); + } + protected String getAddressStringPrefix() { return ADDRESS_PREFIX; } @@ -83,21 +87,27 @@ public static ModbusTagExtendedRegister of(String addressString) { throw new IllegalArgumentException("Address must be less than or equal to " + REGISTER_MAXADDRESS + ". Was " + address); } - String quantityString = matcher.group("quantity"); - int quantity = quantityString != null ? Integer.parseInt(quantityString) : 1; - if ((address + quantity) > REGISTER_MAXADDRESS) { - throw new IllegalArgumentException("Last requested address is out of range, should be between 0 and " + REGISTER_MAXADDRESS + ". Was " + (address + (quantity - 1))); - } - - if (quantity > 125) { - throw new IllegalArgumentException("quantity may not be larger than 125. Was " + quantity); - } - ModbusDataType dataType = (matcher.group("datatype") != null) ? ModbusDataType.valueOf(matcher.group("datatype")) : ModbusDataType.INT; String stringLengthString = matcher.group("stringLength"); Integer stringLength = (stringLengthString != null) ? Integer.parseInt(stringLengthString) : null; - return new ModbusTagExtendedRegister(address, quantity, stringLength, dataType, TagConfigParser.parse(addressString)); + int[] selection = selectionOf(matcher, addressString); + // The offset counts elements; the address counts registers. + address += registerOffsetOf(selection[0], dataType, stringLength, addressString); + int quantity = selection[1]; + // The wire carries registers, not elements: the limits below are about how much a request + // can hold and where the address space ends, so both have to be measured in registers. + int registers = registerCountOf(quantity, dataType, stringLength); + if ((address + registers) > REGISTER_MAXADDRESS) { + throw new IllegalArgumentException("Last requested address is out of range, should be between 0 and " + REGISTER_MAXADDRESS + ". Was " + (address + (registers - 1))); + } + + if (registers > 125) { + throw new IllegalArgumentException("quantity may not be larger than 125 registers. Was " + registers); + } + + + return new ModbusTagExtendedRegister(address, quantity, stringLength, dataType, TagConfigParser.parse(addressString), selection[2] == 1); } } diff --git a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagHoldingRegister.java b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagHoldingRegister.java index 95607175296..161540511e6 100644 --- a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagHoldingRegister.java +++ b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagHoldingRegister.java @@ -43,6 +43,10 @@ public ModbusTagHoldingRegister(int address, Integer quantity, Integer stringLen super(address, quantity, stringLength, dataType, config); } + public ModbusTagHoldingRegister(int address, Integer quantity, Integer stringLength, ModbusDataType dataType, Map config, boolean explicitRange) { + super(address, quantity, stringLength, dataType, config, explicitRange); + } + protected String getAddressStringPrefix() { return ADDRESS_PREFIX; } @@ -81,22 +85,28 @@ public static ModbusTagHoldingRegister of(String addressString) { throw new IllegalArgumentException("Address must be less than or equal to " + REGISTER_MAXADDRESS + ". Was " + (address + PROTOCOL_ADDRESS_OFFSET)); } - String quantityString = matcher.group("quantity"); - int quantity = quantityString != null ? Integer.parseInt(quantityString) : 1; - if ((address + quantity) > REGISTER_MAXADDRESS) { - throw new IllegalArgumentException("Last requested address is out of range, should be between " + PROTOCOL_ADDRESS_OFFSET + " and " + REGISTER_MAXADDRESS + ". Was " + (address + PROTOCOL_ADDRESS_OFFSET + (quantity - 1))); - } - - if (quantity > 125) { - throw new IllegalArgumentException("quantity may not be larger than 125. Was " + quantity); - } - ModbusDataType dataType = (matcher.group("datatype") != null) ? ModbusDataType.valueOf(matcher.group("datatype")) : ModbusDataType.INT; String stringLengthString = matcher.group("stringLength"); Integer stringLength = (stringLengthString != null) ? Integer.parseInt(stringLengthString) : null; - return new ModbusTagHoldingRegister(address, quantity, stringLength, dataType, TagConfigParser.parse(addressString)); + int[] selection = selectionOf(matcher, addressString); + // The offset counts elements; the address counts registers. + address += registerOffsetOf(selection[0], dataType, stringLength, addressString); + int quantity = selection[1]; + // The wire carries registers, not elements: the limits below are about how much a request + // can hold and where the address space ends, so both have to be measured in registers. + int registers = registerCountOf(quantity, dataType, stringLength); + if ((address + registers) > REGISTER_MAXADDRESS) { + throw new IllegalArgumentException("Last requested address is out of range, should be between " + PROTOCOL_ADDRESS_OFFSET + " and " + REGISTER_MAXADDRESS + ". Was " + (address + PROTOCOL_ADDRESS_OFFSET + (registers - 1))); + } + + if (registers > 125) { + throw new IllegalArgumentException("quantity may not be larger than 125 registers. Was " + registers); + } + + + return new ModbusTagHoldingRegister(address, quantity, stringLength, dataType, TagConfigParser.parse(addressString), selection[2] == 1); } } diff --git a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagInputRegister.java b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagInputRegister.java index bb110fc1d25..284cd7d0315 100644 --- a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagInputRegister.java +++ b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/tag/ModbusTagInputRegister.java @@ -43,6 +43,10 @@ public ModbusTagInputRegister(int address, Integer quantity, Integer stringLengt super(address, quantity, stringLength, dataType, config); } + public ModbusTagInputRegister(int address, Integer quantity, Integer stringLength, ModbusDataType dataType, Map config, boolean explicitRange) { + super(address, quantity, stringLength, dataType, config, explicitRange); + } + protected String getAddressStringPrefix() { return ADDRESS_PREFIX; } @@ -81,21 +85,27 @@ public static ModbusTagInputRegister of(String addressString) { throw new IllegalArgumentException("Address must be less than or equal to " + REGISTER_MAXADDRESS + ". Was " + (address + PROTOCOL_ADDRESS_OFFSET)); } - String quantityString = matcher.group("quantity"); - int quantity = quantityString != null ? Integer.parseInt(quantityString) : 1; - if ((address + quantity) > REGISTER_MAXADDRESS) { - throw new IllegalArgumentException("Last requested address is out of range, should be between " + PROTOCOL_ADDRESS_OFFSET + " and " + REGISTER_MAXADDRESS + ". Was " + (address + PROTOCOL_ADDRESS_OFFSET + (quantity - 1))); - } - - if (quantity > 125) { - throw new IllegalArgumentException("quantity may not be larger than 125. Was " + quantity); - } - ModbusDataType dataType = (matcher.group("datatype") != null) ? ModbusDataType.valueOf(matcher.group("datatype")) : ModbusDataType.INT; String stringLengthString = matcher.group("stringLength"); Integer stringLength = (stringLengthString != null) ? Integer.parseInt(stringLengthString) : null; - return new ModbusTagInputRegister(address, quantity, stringLength, dataType, TagConfigParser.parse(addressString)); + int[] selection = selectionOf(matcher, addressString); + // The offset counts elements; the address counts registers. + address += registerOffsetOf(selection[0], dataType, stringLength, addressString); + int quantity = selection[1]; + // The wire carries registers, not elements: the limits below are about how much a request + // can hold and where the address space ends, so both have to be measured in registers. + int registers = registerCountOf(quantity, dataType, stringLength); + if ((address + registers) > REGISTER_MAXADDRESS) { + throw new IllegalArgumentException("Last requested address is out of range, should be between " + PROTOCOL_ADDRESS_OFFSET + " and " + REGISTER_MAXADDRESS + ". Was " + (address + PROTOCOL_ADDRESS_OFFSET + (registers - 1))); + } + + if (registers > 125) { + throw new IllegalArgumentException("quantity may not be larger than 125 registers. Was " + registers); + } + + + return new ModbusTagInputRegister(address, quantity, stringLength, dataType, TagConfigParser.parse(addressString), selection[2] == 1); } } diff --git a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusArrayParityTest.java b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusArrayParityTest.java new file mode 100644 index 00000000000..09c3e31367c --- /dev/null +++ b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusArrayParityTest.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.modbus; + +import org.apache.plc4x.java.api.model.ArrayInfo; +import org.apache.plc4x.java.modbus.base.tag.ModbusTag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The same selection means the same thing here as on every other driver (SC-001). + * + *

Each driver asserts this against its own address syntax; the assertions are deliberately + * identical, because that sameness is the success criterion. + */ +class ModbusArrayParityTest { + + @Test + void aRangeOfEightStartingAtZero() { + List dimensions = ModbusTag.of("holding-register:1[0..7]:INT").getArrayInfo(); + + assertEquals(1, dimensions.size(), "one dimension"); + assertEquals(8, dimensions.get(0).getSize(), "eight elements"); + assertEquals(0, dimensions.get(0).getLowerBound() - dimensions.get(0).getBase(), + "starting at offset zero"); + assertTrue(dimensions.get(0).isRange(), "written as a range"); + } + + @Test + void aBareIndexIsAScalar() { + assertTrue(ModbusTag.of("holding-register:1[4]:INT").getArrayInfo().isEmpty()); + } + + @Test + void anOmittedSelectionIsAScalar() { + assertTrue(ModbusTag.of("holding-register:1:INT").getArrayInfo().isEmpty()); + } +} diff --git a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusBitStringArrayTest.java b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusBitStringArrayTest.java index 8869ab81096..b7ba3dbe0d4 100644 --- a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusBitStringArrayTest.java +++ b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusBitStringArrayTest.java @@ -103,9 +103,9 @@ void stillReadsASingleWordAsAScalar() throws Exception { */ @Test void asksTheDeviceForEveryRegister() { - assertEquals(3, ModbusTagHoldingRegister.of("holding-register:1:WORD[3]").getLengthWords()); - assertEquals(4, ModbusTagHoldingRegister.of("holding-register:1:DWORD[2]").getLengthWords()); - assertEquals(8, ModbusTagHoldingRegister.of("holding-register:1:LWORD[2]").getLengthWords()); + assertEquals(3, ModbusTagHoldingRegister.of("holding-register:1[0..2]:WORD").getLengthWords()); + assertEquals(4, ModbusTagHoldingRegister.of("holding-register:1[0..1]:DWORD").getLengthWords()); + assertEquals(8, ModbusTagHoldingRegister.of("holding-register:1[0..1]:LWORD").getLengthWords()); } private static PlcValue parse(byte[] data, ModbusDataType dataType, int numberOfValues) throws Exception { diff --git a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusEncodeTest.java b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusEncodeTest.java index c9f7d508f1d..e4772eba6e6 100644 --- a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusEncodeTest.java +++ b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusEncodeTest.java @@ -32,7 +32,7 @@ public class ModbusEncodeTest { @Test public void testEncodeBooleanBOOL() { Boolean[] object = {true,false,true,false,true,false,true,true,false}; - ModbusTagCoil coils = ModbusTagCoil.of("coil:8:BOOL[9]"); + ModbusTagCoil coils = ModbusTagCoil.of("coil:8[0..8]:BOOL"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(coils, object); Assertions.assertEquals("[true,false,true,false,true,false,true,true,false]", list.toString()); } @@ -40,7 +40,7 @@ public void testEncodeBooleanBOOL() { @Test public void testEncodeIntegerSINT() { Integer[] object = {1,-1,127,-128,5,6,7,8}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:8:SINT[8]{unit-id: 10}"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:8[0..7]:SINT{unit-id: 10}"); Assertions.assertEquals((short) 10, holdingRegister.getUnitId()); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1,-1,127,-128,5,6,7,8]", list.toString()); @@ -49,7 +49,7 @@ public void testEncodeIntegerSINT() { @Test public void testEncodeIntegerUSINT() { Integer[] object = {1,255,0,4,5,6,7,8}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:8:USINT[8]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:8[0..7]:USINT"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1,255,0,4,5,6,7,8]", list.toString()); } @@ -57,7 +57,7 @@ public void testEncodeIntegerUSINT() { @Test public void testEncodeIntegerBYTE() { Integer[] object = {1,255,0,4,5,6,7,8}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:8:BYTE[8]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:8[0..7]:BYTE"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1,255,0,4,5,6,7,8]", list.toString()); } @@ -65,7 +65,7 @@ public void testEncodeIntegerBYTE() { @Test public void testEncodeIntegerINT() { Integer[] object = {1,-1,32000,-32000,5,6,7}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7:INT[7]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7[0..6]:INT"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1,-1,32000,-32000,5,6,7]", list.toString()); } @@ -73,7 +73,7 @@ public void testEncodeIntegerINT() { @Test public void testEncodeIntegerUINT() { Integer[] object = {1,65535,10,55000,5,6,7}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7:UINT[7]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7[0..6]:UINT"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1,65535,10,55000,5,6,7]", list.toString()); } @@ -81,7 +81,7 @@ public void testEncodeIntegerUINT() { @Test public void testEncodeIntegerWORD() { Integer[] object = {1,65535,10,55000,5,6,7}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7:WORD[7]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7[0..6]:WORD"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1,65535,10,55000,5,6,7]", list.toString()); } @@ -89,7 +89,7 @@ public void testEncodeIntegerWORD() { @Test public void testEncodeIntegerDINT() { Integer[] object = {1,655354775,-2147483648,2147483647,5,6,7}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7:DINT[7]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7[0..6]:DINT"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1,655354775,-2147483648,2147483647,5,6,7]", list.toString()); } @@ -97,7 +97,7 @@ public void testEncodeIntegerDINT() { @Test public void testEncodeLongUDINT() { Long[] object = {1L,655354775L,0L,4294967295L,5L,6L,7L}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7:UDINT[7]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7[0..6]:UDINT"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1,655354775,0,4294967295,5,6,7]", list.toString()); } @@ -105,7 +105,7 @@ public void testEncodeLongUDINT() { @Test public void testEncodeLongDWORD() { Long[] object = {1L,655354775L,0L,4294967295L,5L,6L,7L}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7:DWORD[7]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7[0..6]:DWORD"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1,655354775,0,4294967295,5,6,7]", list.toString()); } @@ -113,7 +113,7 @@ public void testEncodeLongDWORD() { @Test public void testEncodeLongLINT() { Long[] object = {1L,655354775L,-9223372036854775808L,9223372036854775807L,5L,6L,7L}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7:LINT[7]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7[0..6]:LINT"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1,655354775,-9223372036854775808,9223372036854775807,5,6,7]", list.toString()); } @@ -121,7 +121,7 @@ public void testEncodeLongLINT() { @Test public void testEncodeBigIntegerULINT() { BigInteger[] object = {BigInteger.valueOf(1L),BigInteger.valueOf(655354775L),BigInteger.valueOf(0),new BigInteger("18446744073709551615"),BigInteger.valueOf(5L),BigInteger.valueOf(6L),BigInteger.valueOf(7L)}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7:ULINT[7]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7[0..6]:ULINT"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1,655354775,0,18446744073709551615,5,6,7]", list.toString()); } @@ -129,7 +129,7 @@ public void testEncodeBigIntegerULINT() { @Test public void testEncodeBigIntegerLWORD() { BigInteger[] object = {BigInteger.valueOf(1L),BigInteger.valueOf(655354775L),BigInteger.valueOf(0),new BigInteger("18446744073709551615"),BigInteger.valueOf(5L),BigInteger.valueOf(6L),BigInteger.valueOf(7L)}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7:LWORD[7]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7[0..6]:LWORD"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1,655354775,0,18446744073709551615,5,6,7]", list.toString()); } @@ -137,7 +137,7 @@ public void testEncodeBigIntegerLWORD() { @Test public void testEncodeFloatREAL() { Float[] object = {1.1f,1000.1f,100000.1f,3.4028232E38f,-3.4028232E38f,-1f,10384759934840.0f}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7:REAL[7]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7[0..6]:REAL"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); //! When using Java 19 it seems the toString method uses a different precision than the previous versions, //! so we need to check differently in this case. @@ -151,7 +151,7 @@ public void testEncodeFloatREAL() { @Test public void testEncodeDoubleLREAL() { Double[] object = {1.1,1000.1,100000.1,1.7E308,-1.7E308,-1d,10384759934840.0}; - ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7:LREAL[7]"); + ModbusTagHoldingRegister holdingRegister = ModbusTagHoldingRegister.of("holding-register:7[0..6]:LREAL"); PlcList list = (PlcList) new DefaultPlcValueHandler().newPlcValue(holdingRegister, object); Assertions.assertEquals("[1.1,1000.1,100000.1,1.7E308,-1.7E308,-1.0,1.038475993484E13]", list.toString()); } diff --git a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusLegacyAddressTest.java b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusLegacyAddressTest.java new file mode 100644 index 00000000000..d04ced29886 --- /dev/null +++ b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusLegacyAddressTest.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.modbus; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.modbus.base.tag.ModbusTag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Addresses written before the array notation was unified must not parse. + * + *

The brackets moved from after the type to before it, which is what turns an otherwise + * silent change of meaning into a failure: {@code [4]} used to mean four elements and now means + * the fifth, so an unmodified address that still parsed would quietly return different data. + */ +class ModbusLegacyAddressTest { + + @Test + void legacyForm0IsRejected() { + assertThrows(PlcInvalidTagException.class, () -> ModbusTag.of("holding-register:1:INT[4]")); + } + + @Test + void legacyForm1IsRejected() { + assertThrows(PlcInvalidTagException.class, () -> ModbusTag.of("coil:1:BOOL[8]")); + } + + @Test + void theReplacementFormParses() { + assertNotNull(ModbusTag.of("holding-register:1[0..3]:INT")); + } + + /** The message has to hand an upgrading user the address to write, not just a regex. */ + @Test + void theErrorNamesTheReplacementAddress() { + PlcInvalidTagException thrown = + assertThrows(PlcInvalidTagException.class, () -> ModbusTag.of("holding-register:1:INT[4]")); + assertTrue(thrown.getMessage().contains("holding-register:1[0..3]:INT"), thrown::getMessage); + } +} diff --git a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusStringTest.java b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusStringTest.java index 1f556d71673..afa4db184f3 100644 --- a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusStringTest.java +++ b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusStringTest.java @@ -78,7 +78,7 @@ void readsAWideString() throws Exception { @Test void sizesTheRequestFromTheDeclaredLength() { assertEquals(6, ModbusTagHoldingRegister.of("holding-register:1:STRING(12)").getLengthWords()); - assertEquals(18, ModbusTagHoldingRegister.of("holding-register:1:STRING(12)[3]").getLengthWords()); + assertEquals(18, ModbusTagHoldingRegister.of("holding-register:1[0..2]:STRING(12)").getLengthWords()); // A wide character is two bytes, so the same declared length needs twice the registers. assertEquals(12, ModbusTagHoldingRegister.of("holding-register:1:WSTRING(12)").getLengthWords()); } @@ -86,7 +86,7 @@ void sizesTheRequestFromTheDeclaredLength() { @Test void keepsTheDeclaredLengthOnTheTag() { assertEquals(20, ModbusTagHoldingRegister.of("holding-register:1:STRING(20)").getStringLength()); - assertEquals(3, ModbusTagHoldingRegister.of("holding-register:1:STRING(20)[3]").getNumberOfElements()); + assertEquals(3, ModbusTagHoldingRegister.of("holding-register:1[0..2]:STRING(20)").getNumberOfElements()); } /** @@ -114,8 +114,8 @@ void refusesALengthOnANonStringType() { @Test void leavesOtherDataTypesUnchanged() { assertEquals(1, ModbusTagHoldingRegister.of("holding-register:1:INT").getStringLength()); - assertEquals(7, ModbusTagHoldingRegister.of("holding-register:1:INT[7]").getLengthWords()); - assertEquals(14, ModbusTagHoldingRegister.of("holding-register:1:DINT[7]").getLengthWords()); + assertEquals(7, ModbusTagHoldingRegister.of("holding-register:1[0..6]:INT").getLengthWords()); + assertEquals(14, ModbusTagHoldingRegister.of("holding-register:1[0..6]:DINT").getLengthWords()); } private static PlcValue parse(String text, ModbusDataType dataType, int numberOfValues, int stringLength) diff --git a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusTagTest.java b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusTagTest.java index e32c26a73ac..7837952e88f 100644 --- a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusTagTest.java +++ b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusTagTest.java @@ -44,7 +44,9 @@ private void verifyModbusTag(List tagPatterns, for (int i = 1; i <= allowedMax; i++) { List tags = new ArrayList<>(); for (String tagPattern : tagPatterns) { - final ModbusTag modbusTag = ModbusTag.of(String.format(tagPattern, i)); + // The templates spell the selection as an inclusive range, so the last index is + // one below the element count the loop is asserting. + final ModbusTag modbusTag = ModbusTag.of(String.format(tagPattern, i - 1)); assertTrue(expectedClass.isInstance(modbusTag)); assertEquals(i, modbusTag.getNumberOfElements()); tags.add(modbusTag); @@ -83,7 +85,7 @@ private void verifyModbusTag(List tagPatterns, @Test void testCoil_INT_ARRAY_RANGE() { verifyModbusTag( - List.of("coil:1:BOOL[%d]", "00001:BOOL[%d]", "000001:BOOL[%d]", "0x00001:BOOL[%d]"), + List.of("coil:1[0..%d]:BOOL", "00001[0..%d]:BOOL", "000001[0..%d]:BOOL", "0x00001[0..%d]:BOOL"), 2000, ModbusTagCoil.class, PROTOCOL_ADDRESS_OFFSET @@ -93,7 +95,7 @@ void testCoil_INT_ARRAY_RANGE() { @Test void testDiscreteInput_INT_ARRAY_RANGE() { verifyModbusTag( - List.of("discrete-input:1:BOOL[%d]", "10001:BOOL[%d]", "100001:BOOL[%d]", "1x00001:BOOL[%d]"), + List.of("discrete-input:1[0..%d]:BOOL", "10001[0..%d]:BOOL", "100001[0..%d]:BOOL", "1x00001[0..%d]:BOOL"), 2000, ModbusTagDiscreteInput.class, PROTOCOL_ADDRESS_OFFSET @@ -103,7 +105,7 @@ void testDiscreteInput_INT_ARRAY_RANGE() { @Test void testHolding_INT_ARRAY_RANGE() { verifyModbusTag( - List.of("holding-register:1:INT[%d]", "40001:INT[%d]", "400001:INT[%d]", "4x00001:INT[%d]"), + List.of("holding-register:1[0..%d]:INT", "40001[0..%d]:INT", "400001[0..%d]:INT", "4x00001[0..%d]:INT"), 125, ModbusTagHoldingRegister.class, PROTOCOL_ADDRESS_OFFSET @@ -113,7 +115,7 @@ void testHolding_INT_ARRAY_RANGE() { @Test void testInput_INT_ARRAY_RANGE() { verifyModbusTag( - List.of("input-register:1:INT[%d]", "30001:INT[%d]", "300001:INT[%d]", "3x00001:INT[%d]"), + List.of("input-register:1[0..%d]:INT", "30001[0..%d]:INT", "300001[0..%d]:INT", "3x00001[0..%d]:INT"), 125, ModbusTagInputRegister.class, PROTOCOL_ADDRESS_OFFSET @@ -123,7 +125,7 @@ void testInput_INT_ARRAY_RANGE() { @Test void testExtended_INT_ARRAY_RANGE() { verifyModbusTag( - List.of("extended-register:1:INT[%d]", "60001:INT[%d]", "600001:INT[%d]", "6x00001:INT[%d]"), + List.of("extended-register:1[0..%d]:INT", "60001[0..%d]:INT", "600001[0..%d]:INT", "6x00001[0..%d]:INT"), 125, ModbusTagExtendedRegister.class, 0 // Addresses for extended memory start at address 0 instead of 1 @@ -139,7 +141,7 @@ void testExtended_INT_ARRAY_RANGE() { @Test void aCountTooWideToBeANumberIsAnInvalidTagNotANumberFormatError() { assertThrows(PlcInvalidTagException.class, - () -> ModbusTagHoldingRegister.of("holding-register:1:INT[99999999999]")); + () -> ModbusTagHoldingRegister.of("holding-register:1[0..99999999998]:INT")); } @Test diff --git a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/base/tag/ModbusSelectionOffsetTest.java b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/base/tag/ModbusSelectionOffsetTest.java new file mode 100644 index 00000000000..7185d110ae9 --- /dev/null +++ b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/base/tag/ModbusSelectionOffsetTest.java @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.modbus.base.tag; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A selection offset counts elements; a Modbus address counts registers. The two + * are the same number only for a type that occupies one register, which is why this went unnoticed: + * every example used INT. + * + *

The read length already scales - {@code getLengthWords()} multiplies by the data type's size - + * so an unscaled offset does not shorten the read, it moves it, silently, to the wrong registers. + */ +class ModbusSelectionOffsetTest { + + @Test + @DisplayName("a one-register type advances one register per element") + void oneRegisterPerElement() { + // 40001 is address 0; the fifth INT is four registers along. + assertEquals(4, ModbusTagHoldingRegister.of("holding-register:1[4]:INT").getAddress()); + } + + @Test + @DisplayName("a two-register type advances two registers per element") + void twoRegistersPerElement() { + // The fifth DINT begins eight registers along, not four. + assertEquals(8, ModbusTagHoldingRegister.of("holding-register:1[4]:DINT").getAddress()); + } + + @Test + @DisplayName("a four-register type advances four registers per element") + void fourRegistersPerElement() { + assertEquals(8, ModbusTagHoldingRegister.of("holding-register:1[2]:LINT").getAddress()); + } + + @Test + @DisplayName("a string advances by its declared length") + void stringsAdvanceByTheirDeclaredLength() { + // A STRING(20) occupies ten registers, so the third one begins twenty registers along. + assertEquals(20, ModbusTagHoldingRegister.of("holding-register:1[2]:STRING(20)").getAddress()); + } + + @Test + @DisplayName("the same rule applies to input and extended registers") + void theOtherRegisterAreasScaleToo() { + assertEquals(8, ModbusTagInputRegister.of("input-register:1[4]:DINT").getAddress()); + // An extended register address is not shifted by the protocol offset the way the others + // are, so its base stays 1 and the eight-register advance lands on 9. + assertEquals(9, ModbusTagExtendedRegister.of("extended-register:1[4]:DINT").getAddress()); + } + + @Test + @DisplayName("a bit area addresses bits, so its offset is not scaled") + void bitAreasAreNotScaled() { + // One coil is one address; there is no element size to multiply by. + assertEquals(4, ModbusTagCoil.of("coil:1[4]:BOOL").getAddress()); + assertEquals(4, ModbusTagDiscreteInput.of("discrete-input:1[4]:BOOL").getAddress()); + } + + @Test + @DisplayName("the per-request and address-space limits count registers, not elements") + void limitsAreCountedInRegisters() { + // 63 DINTs are 126 registers and do not fit into the 125 a read carries, however few + // elements that is. Counting elements let this through and truncated the request. + assertThrows(IllegalArgumentException.class, + () -> ModbusTagHoldingRegister.of("holding-register:1[0..62]:DINT")); + // 62 DINTs are 124 registers and still fit. + assertDoesNotThrow(() -> ModbusTagHoldingRegister.of("holding-register:1[0..61]:DINT")); + } + + @Test + @DisplayName("a selection starting inside a register is rejected, not rounded") + void aSelectionStartingInsideARegisterIsRejected() { + // Two CHARs share one register - getLengthWords() reports one for them - so an odd + // element offset falls inside a register. Rounding each element up to a whole one placed + // the start where nothing was written. + assertThrows(IllegalArgumentException.class, + () -> ModbusTagHoldingRegister.of("holding-register:1[1..2]:CHAR")); + // An even offset lands on a boundary: two CHARs along is one register along. + assertEquals(1, ModbusTagHoldingRegister.of("holding-register:1[2..3]:CHAR").getAddress()); + } + + @Test + @DisplayName("a value narrower than a register still occupies a whole one") + void aSubRegisterValueStillOccupiesARegister() { + // Halving the byte count and truncating asked for no registers at all. + assertEquals(1, ModbusTagHoldingRegister.of("holding-register:1:CHAR").getLengthWords()); + assertEquals(1, ModbusTagHoldingRegister.of("holding-register:1[0..1]:CHAR").getLengthWords()); + } + + @Test + @DisplayName("a bare index is a scalar; a one-element range is a list of one") + void aRangeIsAnArrayEvenWhenItSpansOneElement() { + // The notation's own rule, and what a consumer reads getArrayInfo() to decide. The count + // cannot express it: both of these select exactly one element. + assertEquals(0, ModbusTagHoldingRegister.of("holding-register:1[4]:INT").getArrayInfo().size(), + "a bare index selects one element, which is a scalar"); + assertEquals(1, ModbusTagHoldingRegister.of("holding-register:1[4..4]:INT").getArrayInfo().size(), + "a range is an array even when it spans one element"); + assertTrue(ModbusTagHoldingRegister.of("holding-register:1[4..4]:INT").getArrayInfo().get(0).isRange()); + } + + @Test + @DisplayName("an address with no selection at all is a scalar") + void noSelectionIsAScalar() { + assertEquals(0, ModbusTagHoldingRegister.of("holding-register:1:INT").getArrayInfo().size()); + } + + @Test + @DisplayName("a multi-element range still reports its elements") + void aRangeReportsItsElements() { + assertEquals(1, ModbusTagHoldingRegister.of("holding-register:1[0..3]:INT").getArrayInfo().size()); + assertEquals(4, ModbusTagHoldingRegister.of("holding-register:1[0..3]:INT").getArrayInfo().get(0).getSize()); + } +} diff --git a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/tag/OpcuaTag.java b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/tag/OpcuaTag.java index b4eecd894dc..b1dcae5be84 100644 --- a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/tag/OpcuaTag.java +++ b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/tag/OpcuaTag.java @@ -18,11 +18,10 @@ */ package org.apache.plc4x.java.opcua.tag; -import java.nio.charset.StandardCharsets; import java.util.Map; -import java.util.Map.Entry; import org.apache.commons.lang3.EnumUtils; import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.api.exceptions.PlcUnsupportedDataTypeException; import org.apache.plc4x.java.api.model.ArrayInfo; import org.apache.plc4x.java.api.model.PlcSubscriptionTag; @@ -34,6 +33,7 @@ import org.apache.plc4x.java.opcua.readwrite.OpcuaIdentifierType; import java.time.Duration; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -43,22 +43,13 @@ public class OpcuaTag implements PlcSubscriptionTag { // Inline tag-config pattern that the old SPI's {@code TagConfigParser} used // to append; kept here so the address-string syntax stays compatible. - private static final String TAG_CONFIG_PATTERN = "(\\|(?(?:(?:[a-zA-Z\\-_]+=[a-zA-Z0-9\\-_]+)(?:,(?:[a-zA-Z\\-_]+=[a-zA-Z0-9\\-_]+))*)))?"; + private static final String TAG_CONFIG_PATTERN = "(\\|(?[a-zA-Z\\-_]+=[a-zA-Z0-9\\-_]+(?:,[a-zA-Z\\-_]+=[a-zA-Z0-9\\-_]+)*))?"; // The identifier is any run of non-';' characters, except that a bracketed segment '[...]' may // itself contain ';' — this lets an array-index suffix carry a ';base' (e.g. "Foo[3..8;1]") // without the inner ';' being mistaken for the ';a='/';TYPE' delimiters that follow. - private static final String OPC_UTA_TAG_ADDRESS = "^ns=(?\\d+);(?[isgb])=(?(?:[^;\\[]|\\[[^\\]]*\\])+)?(;a=(?[^;]+))?(;(?[a-zA-Z_]+))?"; + private static final String OPC_UTA_TAG_ADDRESS = "^ns=(?\\d+);(?[isgb])=(?(?:[^;\\[]|\\[[^]]*])+)?(;a=(?[^;]+))?(;(?[a-zA-Z_]+))?"; public static final Pattern ADDRESS_PATTERN = Pattern.compile(OPC_UTA_TAG_ADDRESS + TAG_CONFIG_PATTERN + "$"); - // A trailing run of array-index brackets on the identifier, e.g. "[8]", "[3..8]" or the - // multi-dimensional "[1..2][0..5;1]". Each bracket is a single index or an inclusive "lo..hi" - // range, with an optional ";base" giving the array's lower bound (default 0). The grammar is - // strictly numeric so a normal string identifier that happens to contain '[' is left untouched. - private static final Pattern INDEX_RANGE_PATTERN = - Pattern.compile("(\\[\\d+(?:\\.\\.\\d+)?(?:;\\d+)?\\])+$"); - private static final Pattern SINGLE_BRACKET_PATTERN = - Pattern.compile("\\[(\\d+)(?:\\.\\.(\\d+))?(?:;(\\d+))?\\]"); - private final OpcuaIdentifierType identifierType; private final int namespace; @@ -110,11 +101,12 @@ public static OpcuaTag of(String address) { String indexRangeExpression = null; String indexRange = null; if (identifier != null) { - Matcher indexMatcher = INDEX_RANGE_PATTERN.matcher(identifier); - if (indexMatcher.find()) { - indexRangeExpression = indexMatcher.group(); - indexRange = toOpcuaIndexRange(address, indexRangeExpression); - identifier = identifier.substring(0, indexMatcher.start()); + String expression = ArrayNotationParser.expressionPart(identifier); + if (!expression.isEmpty()) { + List dimensions = ArrayNotationParser.parse(expression, address); + indexRangeExpression = ArrayNotationParser.render(dimensions); + indexRange = toOpcuaIndexRange(dimensions); + identifier = ArrayNotationParser.addressPart(identifier); } } @@ -144,29 +136,19 @@ public static OpcuaTag of(String address) { } /** - * Translates the user's array-index expression into an OPC UA IndexRange string. Each bracket - * becomes one dimension: a single index {@code [n]} or an inclusive range {@code [lo..hi]}, - * with an optional {@code ;base} lower bound (default 0) subtracted so the result is 0-based, as - * OPC UA requires. Dimensions are comma-separated. Example: {@code [3..8;1]} -> {@code "2:7"}. + * Renders parsed dimensions as an OPC UA IndexRange string: 0-based, inclusive, one entry per + * dimension, comma-separated. The declared lower bound has already been applied by the shared + * parser, so this only formats. Example: {@code [3..8;1]} -> {@code "2:7"}. */ - private static String toOpcuaIndexRange(String address, String indexRangeExpression) { + private static String toOpcuaIndexRange(List dimensions) { StringBuilder result = new StringBuilder(); - Matcher bracket = SINGLE_BRACKET_PATTERN.matcher(indexRangeExpression); - while (bracket.find()) { - long low = Long.parseLong(bracket.group(1)); - long high = bracket.group(2) != null ? Long.parseLong(bracket.group(2)) : low; - long base = bracket.group(3) != null ? Long.parseLong(bracket.group(3)) : 0; - low -= base; - high -= base; - if (low < 0 || high < low) { - throw new PlcInvalidTagException("Invalid array index range '" + bracket.group() - + "' in tag '" + address + "': resolved to " + low + ".." + high - + " (indices must be non-negative and low <= high after applying the base)"); - } - if (result.length() > 0) { + for (ArrayInfo dimension : dimensions) { + int low = dimension.getLowerBound() - dimension.getBase(); + int high = dimension.getUpperBound() - dimension.getBase(); + if (!result.isEmpty()) { result.append(','); } - result.append(low == high ? Long.toString(low) : low + ":" + high); + result.append(low == high ? Integer.toString(low) : low + ":" + high); } return result.toString(); } @@ -249,8 +231,18 @@ public PlcValueType getPlcValueType() { } @Override + /** + * The shape of the value the caller receives: empty for a scalar, one entry per dimension for + * an array. A bare index selects one element and so reports empty; a range reports its + * dimensions even when it spans a single element. The IndexRange actually sent is separate - + * see {@link #getIndexRange()}. + */ public List getArrayInfo() { - return PlcSubscriptionTag.super.getArrayInfo(); + if (indexRangeExpression == null + || ArrayNotationParser.selectsSingleElement(indexRangeExpression)) { + return Collections.emptyList(); + } + return ArrayNotationParser.parse(indexRangeExpression, getAddressString()); } @Override @@ -258,16 +250,15 @@ public boolean equals(Object o) { if (this == o) { return true; } - if (!(o instanceof OpcuaTag)) { - return false; + if (o instanceof OpcuaTag that) { + return namespace == that.namespace && + identifier.equals(that.identifier) && + identifierType == that.identifierType && + attributeId == that.attributeId && + Objects.equals(indexRange, that.indexRange) && + config.equals(that.config); } - OpcuaTag that = (OpcuaTag) o; - return namespace == that.namespace && - identifier.equals(that.identifier) && - identifierType == that.identifierType && - attributeId == that.attributeId && - Objects.equals(indexRange, that.indexRange) && - config.equals(that.config); + return false; } @Override diff --git a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/manual/ManualOpcUaS71500NewFWBrowse.java b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/manual/ManualOpcUaS71500NewFWBrowse.java index ee64a7cd252..92c24822335 100644 --- a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/manual/ManualOpcUaS71500NewFWBrowse.java +++ b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/manual/ManualOpcUaS71500NewFWBrowse.java @@ -32,7 +32,7 @@ public class ManualOpcUaS71500NewFWBrowse { public static void main(String[] args) throws Exception { long startTime = System.currentTimeMillis(); - try (PlcConnection connection = PlcDriverManager.getDefault().getConnectionFactory().getConnection("opcua://192.168.24.66:4840")){ + try (PlcConnection connection = PlcDriverManager.getDefault().getConnectionFactory().getConnection("opcua://192.168.24.66:4840?message-security=NONE&insecure-certificate-verification=true")){ PlcBrowseResponse plcBrowseResponse = connection.browseRequestBuilder() .addQuery("all", "**") .build().executeWithInterceptor((queryName, query, item) -> { diff --git a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/manual/ManualOpcUaS71500NewFWDriverTest.java b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/manual/ManualOpcUaS71500NewFWDriverTest.java index 1f8b23d9251..de6cfa288fc 100644 --- a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/manual/ManualOpcUaS71500NewFWDriverTest.java +++ b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/manual/ManualOpcUaS71500NewFWDriverTest.java @@ -34,7 +34,7 @@ public ManualOpcUaS71500NewFWDriverTest(String connectionString) { public static void main(String[] args) throws Exception { boolean testArrays = true; - ManualOpcUaS71500NewFWDriverTest test = new ManualOpcUaS71500NewFWDriverTest("opcua://192.168.24.66:4840"); + ManualOpcUaS71500NewFWDriverTest test = new ManualOpcUaS71500NewFWDriverTest("opcua://192.168.24.66:4840?message-security=NONE&insecure-certificate-verification=true"); test.addTestCase(/*"g_b1",*/ "ns=3;s=\"OPC_UA_DB\".\"OPC Data\".\"g_b1\"", new PlcBOOL(true)); test.addTestCase(/*"g_b8",*/ "ns=3;s=\"OPC_UA_DB\".\"OPC Data\".\"g_b8\"", new PlcBYTE(0xAB)); test.addTestCase(/*"g_s8",*/ "ns=3;s=\"OPC_UA_DB\".\"OPC Data\".\"g_s8\"", new PlcSINT(-12)); diff --git a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/tag/OpcuaIndexRangeTest.java b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/tag/OpcuaIndexRangeTest.java new file mode 100644 index 00000000000..d01f7099e88 --- /dev/null +++ b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/tag/OpcuaIndexRangeTest.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.opcua.tag; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The OPC UA IndexRange the shared notation produces. + * + *

OPC-UA was the reference implementation the grammar was extracted from, so its wire output + * must be exactly what it was before: 0-based, inclusive, one entry per dimension, comma + * separated - and absent altogether when the address selects nothing, which is how OPC UA asks + * for a whole node. + */ +class OpcuaIndexRangeTest { + + @Test + void aSingleIndexBecomesOneEntry() { + assertEquals("8", OpcuaTag.of("ns=2;i=MyInt[8];DINT").getIndexRange()); + } + + @Test + void aRangeBecomesLowColonHigh() { + assertEquals("3:8", OpcuaTag.of("ns=2;i=MyInt[3..8];DINT").getIndexRange()); + } + + /** The declared base is applied, so the wire form is always 0-based. */ + @Test + void theDeclaredBaseIsResolvedAway() { + assertEquals("2:7", OpcuaTag.of("ns=2;i=MyInt[3..8;1];DINT").getIndexRange()); + } + + @Test + void dimensionsAreCommaJoined() { + assertEquals("1:2,0:5", OpcuaTag.of("ns=2;i=MyInt[1..2][0..5];DINT").getIndexRange()); + } + + /** The comma spelling of the same selection produces the same IndexRange. */ + @Test + void theCommaSpellingProducesTheSameIndexRange() { + assertEquals( + OpcuaTag.of("ns=2;i=MyInt[1..2][0..5];DINT").getIndexRange(), + OpcuaTag.of("ns=2;i=MyInt[1..2,0..5];DINT").getIndexRange()); + } + + /** + * No selection means no IndexRange, which is how OPC UA asks for the whole node - the value + * of FR-022 for a driver that can determine the extent. + */ + @Test + void noSelectionMeansNoIndexRange() { + assertNull(OpcuaTag.of("ns=2;i=MyInt;DINT").getIndexRange()); + assertTrue(OpcuaTag.of("ns=2;i=MyInt;DINT").getArrayInfo().isEmpty()); + } + + /** A bare index yields a scalar, so it reports no array info even though it has a range. */ + @Test + void aBareIndexIsAScalarToTheCaller() { + assertEquals("8", OpcuaTag.of("ns=2;i=MyInt[8];DINT").getIndexRange()); + assertTrue(OpcuaTag.of("ns=2;i=MyInt[8];DINT").getArrayInfo().isEmpty()); + assertFalse(OpcuaTag.of("ns=2;i=MyInt[8..8];DINT").getArrayInfo().isEmpty()); + } +} diff --git a/plc4j/drivers/open-protocol/src/main/java/org/apache/plc4x/java/openprotocol/tag/OpenProtocolTag.java b/plc4j/drivers/open-protocol/src/main/java/org/apache/plc4x/java/openprotocol/tag/OpenProtocolTag.java index 8b94ad63fab..3f5466b6061 100644 --- a/plc4j/drivers/open-protocol/src/main/java/org/apache/plc4x/java/openprotocol/tag/OpenProtocolTag.java +++ b/plc4j/drivers/open-protocol/src/main/java/org/apache/plc4x/java/openprotocol/tag/OpenProtocolTag.java @@ -18,21 +18,43 @@ */ package org.apache.plc4x.java.openprotocol.tag; +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; import org.apache.plc4x.java.api.model.ArrayInfo; import org.apache.plc4x.java.api.model.PlcTag; import org.apache.plc4x.java.api.types.PlcValueType; import java.util.List; +/** + * Placeholder for an Open Protocol tag. This driver has no tag addressing yet: there is no + * address syntax to parse and nothing for a tag to carry. + * + *

Every entry point says so rather than handing back something empty. + * {@link OpenProtocolTagHandler#parseTag(String)} already threw; {@link #of(String)} returned + * {@code null} for the same input, which the driver passed on to the caller as a tag - so the + * failure surfaced later, somewhere else, as a {@code NullPointerException}.

+ */ public class OpenProtocolTag implements PlcTag { + private static final String NOT_IMPLEMENTED = + "The Open Protocol driver does not support tag addressing yet"; + + /** + * @throws PlcInvalidTagException always - see the class comment. Every other driver's + * {@code of()} throws this for an address it cannot parse, and this driver cannot + * parse any. + */ public static OpenProtocolTag of(String addressString) { - return null; + throw new PlcInvalidTagException(NOT_IMPLEMENTED + ": '" + addressString + "'"); } + /** + * @throws UnsupportedOperationException always - a tag of this type carries no address to + * render, and reporting an empty one would read as an address that is simply blank. + */ @Override public String getAddressString() { - return null; + throw new UnsupportedOperationException(NOT_IMPLEMENTED); } @Override diff --git a/plc4j/drivers/open-protocol/src/test/java/org/apache/plc4x/java/openprotocol/SmallSurfaceTest.java b/plc4j/drivers/open-protocol/src/test/java/org/apache/plc4x/java/openprotocol/SmallSurfaceTest.java index 148191ab702..1cfb8ae3944 100644 --- a/plc4j/drivers/open-protocol/src/test/java/org/apache/plc4x/java/openprotocol/SmallSurfaceTest.java +++ b/plc4j/drivers/open-protocol/src/test/java/org/apache/plc4x/java/openprotocol/SmallSurfaceTest.java @@ -37,19 +37,26 @@ class SmallSurfaceTest { @Test - void tagOfIsCurrentlyAPlaceholder() { + void tagOfSaysThereIsNoTagAddressingYet() { // Marker behaviour — kept here so anyone replacing this stub knows to // also remove this test (or replace it with a real parsing assertion). - assertThat(OpenProtocolTag.of("any")).isNull(); + // It used to return null, which the driver's prepareTag() handed to the caller as + // though it were a tag; the failure then surfaced later as a NullPointerException, + // somewhere with nothing left to say about the address that caused it. + assertThatThrownBy(() -> OpenProtocolTag.of("any")) + .isInstanceOf(PlcInvalidTagException.class) + .hasMessageContaining("any"); } @Test void tagDelegatesArrayInfoAndValueTypeToPlcTagDefaults() { OpenProtocolTag tag = new OpenProtocolTag(); // Defaults from PlcTag — non-null collections and a default value type. - assertThat(tag.getAddressString()).isNull(); assertThat(tag.getArrayInfo()).isNotNull(); assertThat(tag.getPlcValueType()).isNotNull(); + // But there is no address to render, and saying so beats reporting a blank one. + assertThatThrownBy(tag::getAddressString) + .isInstanceOf(UnsupportedOperationException.class); } @Test diff --git a/plc4j/drivers/profinet-ng/src/main/java/org/apache/plc4x/java/profinet/tag/ProfinetTag.java b/plc4j/drivers/profinet-ng/src/main/java/org/apache/plc4x/java/profinet/tag/ProfinetTag.java index e4f146479aa..cb444500583 100644 --- a/plc4j/drivers/profinet-ng/src/main/java/org/apache/plc4x/java/profinet/tag/ProfinetTag.java +++ b/plc4j/drivers/profinet-ng/src/main/java/org/apache/plc4x/java/profinet/tag/ProfinetTag.java @@ -21,15 +21,19 @@ import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; import org.apache.plc4x.java.api.model.ArrayInfo; import org.apache.plc4x.java.api.model.PlcTag; +import org.apache.plc4x.java.spi.drivers.model.AddressConstraints; +import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.api.types.PlcValueType; +import java.util.Collections; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; public class ProfinetTag implements PlcTag { - public static final Pattern ADDRESS_PATTERN = Pattern.compile("(?\\d{1,5}).(?\\d{1,5}).(?INPUT|OUTPUT)(.(?\\d{1,5}))?:(?[a-zA-Z_]+)(\\[(?\\d{1,5})])?"); + public static final Pattern ADDRESS_PATTERN = Pattern.compile("(?\\d{1,5}).(?\\d{1,5}).(?INPUT|OUTPUT)(.(?\\d{1,5}))?" + ArrayNotationParser.ARRAY_GROUP + ":(?[a-zA-Z_]+)"); private final int slot; private final int subSlot; private final Direction direction; @@ -37,7 +41,18 @@ public class ProfinetTag implements PlcTag { private final PlcValueType dataType; private final int numElements; + /** + * Whether the address wrote the selection as a range. A one-element range is still a range, + * which no count can express. + */ + private final boolean explicitRange; + public ProfinetTag(int slot, int subSlot, Direction direction, int index, PlcValueType dataType, int numElements) { + this(slot, subSlot, direction, index, dataType, numElements, numElements > 1); + } + + public ProfinetTag(int slot, int subSlot, Direction direction, int index, PlcValueType dataType, int numElements, boolean explicitRange) { + this.explicitRange = explicitRange; this.slot = slot; this.subSlot = subSlot; this.direction = direction; @@ -49,20 +64,64 @@ public ProfinetTag(int slot, int subSlot, Direction direction, int index, PlcVal } } + + /** + * Resolves the address's array expression to the number of elements. This driver addresses a + * named variable rather than a numeric offset, so a selection that does not start at the + * first element has nothing to apply to and is reported rather than quietly ignored. + */ + /** + * Whether the address wrote a range. Not derivable from the element count: {@code [4]} and + * {@code [4..4]} both select one element, and only the range is an array. + */ + private static boolean rangeWritten(Matcher matcher, String addressString) { + String expression = matcher.group("array"); + if (expression == null) { + return false; + } + return ArrayNotationParser.parse(expression, addressString, AddressConstraints.SINGLE_DIMENSION) + .get(0).isRange(); + } + + private static int elementsOf(Matcher matcher, String addressString) { + String expression = matcher.group("array"); + if (expression == null) { + return 1; + } + ArrayInfo dimension = ArrayNotationParser + .parse(expression, addressString, AddressConstraints.SINGLE_DIMENSION).get(0); + if (dimension.getLowerBound() - dimension.getBase() != 0) { + throw new PlcInvalidTagException("Array selection '" + expression + "' in tag '" + + addressString + "' must start at the first element: this driver addresses a " + + "named variable, so there is no offset to start from"); + } + return dimension.getSize(); + } + public static ProfinetTag of(String addressString) { Matcher matcher = ADDRESS_PATTERN.matcher(addressString); if (!matcher.matches()) { - throw new PlcInvalidTagException(addressString, ADDRESS_PATTERN); + throw ArrayNotationParser.invalidAddress(addressString, + "{slot}.{subSlot}.{INPUT|OUTPUT}.{index}[selection]:{TYPE}" + + " - for example 1.2.INPUT.0[0..3]:INT"); } int slot = Integer.parseInt(matcher.group("slot")); int subSlot = Integer.parseInt(matcher.group("subSlot")); Direction direction = Direction.valueOf(matcher.group("direction")); - int index = Integer.parseInt(matcher.group("index")); + // The index is optional in the pattern but has always been required in practice - it was + // parsed unguarded, so an address without one failed with a NumberFormatException. Say so. + String indexToken = matcher.group("index"); + if (indexToken == null) { + throw new PlcInvalidTagException("Address '" + addressString + "' is missing the index:" + + " expected {slot}.{subSlot}.{INPUT|OUTPUT}.{index}[selection]:{TYPE}"); + } + int index = Integer.parseInt(indexToken); PlcValueType dataType = PlcValueType.valueOf(matcher.group("dataType")); - int numElements = (matcher.group("numElements") != null) ? Integer.parseInt(matcher.group("numElements")) : 1; + int numElements = elementsOf(matcher, addressString); - return new ProfinetTag(slot, subSlot, direction, index, dataType, numElements); + return new ProfinetTag(slot, subSlot, direction, index, dataType, numElements, + rangeWritten(matcher, addressString)); } public int getSlot() { @@ -97,7 +156,11 @@ public PlcValueType getPlcValueType() { @Override public List getArrayInfo() { - return PlcTag.super.getArrayInfo(); + // A range is an array even when it spans one element; the count cannot express that. + if (explicitRange) { + return Collections.singletonList(new DefaultArrayInfo(0, numElements - 1, 0, true)); + } + return Collections.emptyList(); } public static enum Direction { diff --git a/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/ManualProfinetIoTestSimocodePN.java b/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/ManualProfinetIoTestSimocodePN.java index 7b1c82b1bc8..44b231ef6e4 100644 --- a/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/ManualProfinetIoTestSimocodePN.java +++ b/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/ManualProfinetIoTestSimocodePN.java @@ -44,7 +44,7 @@ public static void main(String[] args) throws Exception { // Create and execute the subscription request. PlcSubscriptionRequest subscriptionRequest = connection.subscriptionRequestBuilder() - .addCyclicTagAddress("inputs", "1.1.INPUT.0:BYTE[10]", Duration.ofMillis(400)) + .addCyclicTagAddress("inputs", "1.1.INPUT.0[0..9]:BYTE", Duration.ofMillis(400)) // .addCyclicTagAddress("output", "1.1.OUTPUT.0:DWORD", Duration.ofMillis(400)) .build(); PlcSubscriptionResponse subscriptionResponse = subscriptionRequest.execute().get(100000, TimeUnit.MILLISECONDS); diff --git a/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/ManualProfinetIoTestZylkSimocode.java b/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/ManualProfinetIoTestZylkSimocode.java index b85246a15f1..422d4db7af4 100644 --- a/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/ManualProfinetIoTestZylkSimocode.java +++ b/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/ManualProfinetIoTestZylkSimocode.java @@ -34,7 +34,7 @@ public static void main(String[] args) throws Exception { try(PlcConnection connection = new DefaultPlcDriverManager().getConnection("profinet:raw://192.168.54.23")) { // Create and execute the subscription request. PlcSubscriptionRequest subscriptionRequest = connection.subscriptionRequestBuilder() - .addCyclicTagAddress("inputs", "1.1.INPUT.0:BYTE[10]", Duration.ofMillis(400)) + .addCyclicTagAddress("inputs", "1.1.INPUT.0[0..9]:BYTE", Duration.ofMillis(400)) .addCyclicTagAddress("output", "1.1.OUTPUT.0:DWORD", Duration.ofMillis(400)) .build(); PlcSubscriptionResponse subscriptionResponse = subscriptionRequest.execute().get(100000, TimeUnit.MILLISECONDS); diff --git a/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetNgLegacyAddressTest.java b/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetNgLegacyAddressTest.java new file mode 100644 index 00000000000..97811d2c423 --- /dev/null +++ b/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetNgLegacyAddressTest.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.profinet.tag; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.profinet.tag.ProfinetTag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Addresses written before the array notation was unified must not parse. + * + *

The brackets moved from after the type to before it, which is what turns an otherwise + * silent change of meaning into a failure: {@code [4]} used to mean four elements and now means + * the fifth, so an unmodified address that still parsed would quietly return different data. + */ +class ProfinetNgLegacyAddressTest { + + @Test + void legacyForm0IsRejected() { + assertThrows(PlcInvalidTagException.class, () -> ProfinetTag.of("1.2.INPUT.0:INT[4]")); + } + + /** The index is required; an address without one is reported rather than crashing. */ + @Test + void anAddressWithoutAnIndexIsReported() { + assertThrows(PlcInvalidTagException.class, () -> ProfinetTag.of("1.2.INPUT:INT")); + } + + @Test + void theReplacementFormParses() { + assertNotNull(ProfinetTag.of("1.2.INPUT.0[0..3]:INT")); + } +} diff --git a/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetTagTest.java b/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetTagTest.java index 2a2c584edc3..9ee6919b74e 100644 --- a/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetTagTest.java +++ b/plc4j/drivers/profinet-ng/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetTagTest.java @@ -29,7 +29,7 @@ class ProfinetTagTest { @Test void parsesFullAddress() { - ProfinetTag tag = ProfinetTag.of("1.2.INPUT.3:INT[4]"); + ProfinetTag tag = ProfinetTag.of("1.2.INPUT.3[0..3]:INT"); assertThat(tag.getSlot()).isEqualTo(1); assertThat(tag.getSubSlot()).isEqualTo(2); assertThat(tag.getDirection()).isEqualTo(ProfinetTag.Direction.INPUT); @@ -74,7 +74,7 @@ void tagHandlerParsesTagsAndStubsQueries() { @Test void aCountTooWideToBeANumberIsAnInvalidTagNotANumberFormatError() { - assertThatThrownBy(() -> ProfinetTag.of("1.1.INPUT.1:BOOL[99999999999]")) + assertThatThrownBy(() -> ProfinetTag.of("1.1.INPUT.1[0..99999999998]:BOOL")) .isInstanceOf(PlcInvalidTagException.class); } diff --git a/plc4j/drivers/profinet/src/main/java/org/apache/plc4x/java/profinet/tag/ProfinetTag.java b/plc4j/drivers/profinet/src/main/java/org/apache/plc4x/java/profinet/tag/ProfinetTag.java index 43e579c2994..c50d8260fd7 100644 --- a/plc4j/drivers/profinet/src/main/java/org/apache/plc4x/java/profinet/tag/ProfinetTag.java +++ b/plc4j/drivers/profinet/src/main/java/org/apache/plc4x/java/profinet/tag/ProfinetTag.java @@ -21,20 +21,35 @@ import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; import org.apache.plc4x.java.api.model.ArrayInfo; import org.apache.plc4x.java.api.model.PlcTag; +import org.apache.plc4x.java.spi.drivers.model.AddressConstraints; +import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.api.types.PlcValueType; +import java.util.Collections; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; public class ProfinetTag implements PlcTag { - public static final Pattern ADDRESS_PATTERN = Pattern.compile("(?

[\\w\\-. ]+)(:(?[a-zA-Z_]+)){1}(\\[(?\\d{1,5})])?"); + public static final Pattern ADDRESS_PATTERN = Pattern.compile("(?
[\\w\\-. ]+)" + ArrayNotationParser.ARRAY_GROUP + "(:(?[a-zA-Z_]+)){1}"); private final String address; private final int quantity; + + /** + * Whether the address wrote the selection as a range. A one-element range is still a range, + * which no count can express. + */ + private final boolean explicitRange; private final PlcValueType dataType; protected ProfinetTag(String address, Integer quantity, PlcValueType dataType) { + this(address, quantity, dataType, (quantity != null) && (quantity > 1)); + } + + protected ProfinetTag(String address, Integer quantity, PlcValueType dataType, boolean explicitRange) { + this.explicitRange = explicitRange; this.address = address; this.quantity = (quantity != null) ? quantity : 1; if (this.quantity <= 0) { @@ -43,21 +58,59 @@ protected ProfinetTag(String address, Integer quantity, PlcValueType dataType) { this.dataType = dataType; } + + /** + * Resolves the address's array expression to the number of elements. This driver addresses a + * named variable rather than a numeric offset, so a selection that does not start at the + * first element has nothing to apply to and is reported rather than quietly ignored. + */ + /** + * Whether the address wrote a range. Not derivable from the element count: {@code [4]} and + * {@code [4..4]} both select one element, and only the range is an array. + */ + private static boolean rangeWritten(Matcher matcher, String addressString) { + String expression = matcher.group("array"); + if (expression == null) { + return false; + } + return ArrayNotationParser.parse(expression, addressString, AddressConstraints.SINGLE_DIMENSION) + .get(0).isRange(); + } + + private static int elementsOf(Matcher matcher, String addressString) { + String expression = matcher.group("array"); + if (expression == null) { + return 1; + } + ArrayInfo dimension = ArrayNotationParser + .parse(expression, addressString, AddressConstraints.SINGLE_DIMENSION).get(0); + if (dimension.getLowerBound() - dimension.getBase() != 0) { + throw new PlcInvalidTagException("Array selection '" + expression + "' in tag '" + + addressString + "' must start at the first element: this driver addresses a " + + "named variable, so there is no offset to start from"); + } + return dimension.getSize(); + } + public static ProfinetTag of(String addressString) { Matcher matcher = ADDRESS_PATTERN.matcher(addressString); if (!matcher.matches()) { - throw new PlcInvalidTagException(addressString, ADDRESS_PATTERN); + throw ArrayNotationParser.invalidAddress(addressString, + "{address}[selection]:{TYPE} - for example foo.bar[0..3]:DINT"); } - String quantity = matcher.group("quantity") == null ? "1" : matcher.group("quantity"); + int quantity = elementsOf(matcher, addressString); PlcValueType plcValueType = PlcValueType.valueOf(matcher.group("datatype")); - return new ProfinetTag(matcher.group("address"), Integer.parseInt(quantity), plcValueType); + return new ProfinetTag(matcher.group("address"), quantity, plcValueType, + rangeWritten(matcher, addressString)); } @Override public String getAddressString() { - return address; + // Unchanged from before the notation migration: the type is not part of what this + // driver reports, so the result does not round-trip. Only the selection is added. + return address + ArrayNotationParser.render(getArrayInfo()); } @Override @@ -67,6 +120,10 @@ public PlcValueType getPlcValueType() { @Override public List getArrayInfo() { - return PlcTag.super.getArrayInfo(); + // A range is an array even when it spans one element; the count cannot express that. + if (explicitRange) { + return Collections.singletonList(new DefaultArrayInfo(0, quantity - 1, 0, true)); + } + return Collections.emptyList(); } } diff --git a/plc4j/drivers/profinet/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetLegacyAddressTest.java b/plc4j/drivers/profinet/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetLegacyAddressTest.java new file mode 100644 index 00000000000..629032c4105 --- /dev/null +++ b/plc4j/drivers/profinet/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetLegacyAddressTest.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.profinet.tag; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.profinet.tag.ProfinetTag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Addresses written before the array notation was unified must not parse. + * + *

The brackets moved from after the type to before it, which is what turns an otherwise + * silent change of meaning into a failure: {@code [4]} used to mean four elements and now means + * the fifth, so an unmodified address that still parsed would quietly return different data. + */ +class ProfinetLegacyAddressTest { + + @Test + void legacyForm0IsRejected() { + assertThrows(PlcInvalidTagException.class, () -> ProfinetTag.of("foo.bar:DINT[4]")); + } + + @Test + void theReplacementFormParses() { + assertNotNull(ProfinetTag.of("foo.bar[0..3]:DINT")); + } + + /** The message has to hand an upgrading user the address to write, not just a regex. */ + @Test + void theErrorNamesTheReplacementAddress() { + PlcInvalidTagException thrown = + assertThrows(PlcInvalidTagException.class, () -> ProfinetTag.of("foo.bar:DINT[4]")); + assertTrue(thrown.getMessage().contains("foo.bar[0..3]:DINT"), thrown::getMessage); + } +} diff --git a/plc4j/drivers/profinet/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetTagTest.java b/plc4j/drivers/profinet/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetTagTest.java index e25854cd438..bb9506b36a0 100644 --- a/plc4j/drivers/profinet/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetTagTest.java +++ b/plc4j/drivers/profinet/src/test/java/org/apache/plc4x/java/profinet/tag/ProfinetTagTest.java @@ -38,7 +38,7 @@ void parsesScalarTag() { @Test void parsesQuantitySuffix() { - ProfinetTag tag = ProfinetTag.of("foo:INT[4]"); + ProfinetTag tag = ProfinetTag.of("foo[0..3]:INT"); assertThat(tag.getPlcValueType()).isEqualTo(PlcValueType.INT); } @@ -82,7 +82,7 @@ static ProfinetTag of(String address, int quantity) { @Test void aCountTooWideToBeANumberIsAnInvalidTagNotANumberFormatError() { - assertThatThrownBy(() -> ProfinetTag.of("foo:BOOL[99999999999]")) + assertThatThrownBy(() -> ProfinetTag.of("foo[0..99999999998]:BOOL")) .isInstanceOf(PlcInvalidTagException.class); } } diff --git a/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/tag/S7StringFixedLengthTag.java b/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/tag/S7StringFixedLengthTag.java index a0f3c3d5477..8679d6119bd 100644 --- a/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/tag/S7StringFixedLengthTag.java +++ b/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/tag/S7StringFixedLengthTag.java @@ -33,10 +33,10 @@ public class S7StringFixedLengthTag extends S7Tag { public static final Pattern DATA_BLOCK_STRING_FIXED_LENGTH_ADDRESS_PATTERN = - Pattern.compile("^%DB(?\\d{1,5}).DB(?[XBWD]?)(?\\d{1,7})(.(?[0-7]))?:(?STRING|WSTRING)\\((?\\d{1,3})\\)(\\[(?\\d{1,7})])?"); + Pattern.compile("^%DB(?\\d{1,5}).DB(?[XBWD]?)(?\\d{1,7})(.(?[0-7]))?" + ARRAY_EXPRESSION + ":(?STRING|WSTRING)\\((?\\d{1,3})\\)"); public static final Pattern DATA_BLOCK_STRING_FIXED_LENGTH_SHORT_PATTERN = - Pattern.compile("^%DB(?\\d{1,5}):(?\\d{1,7})(.(?[0-7]))?:(?STRING|WSTRING)\\((?\\d{1,3})\\)(\\[(?\\d{1,7})])?"); + Pattern.compile("^%DB(?\\d{1,5}):(?\\d{1,7})(.(?[0-7]))?" + ARRAY_EXPRESSION + ":(?STRING|WSTRING)\\((?\\d{1,3})\\)"); private final int stringLength; @@ -52,6 +52,17 @@ public int getStringLength() { return stringLength; } + /** + * Spells the tag the way {@link #of(String)} parses it back, which means carrying the + * declared length: without it the address reads as a variable-length string, and the length + * is what decides the layout when the string is read or written. + */ + @Override + public String getAddressString() { + // The base form ends in ":STRING"; the declared length belongs directly behind it. + return super.getAddressString() + "(" + stringLength + ")"; + } + public static boolean matches(String address) { return DATA_BLOCK_STRING_FIXED_LENGTH_ADDRESS_PATTERN.matcher(address).matches() || DATA_BLOCK_STRING_FIXED_LENGTH_SHORT_PATTERN.matcher(address).matches(); @@ -128,12 +139,11 @@ public static S7StringFixedLengthTag of(String address) { } else if (dataType == TransportSize.BOOL) { throw new PlcInvalidTagException("Expected bit offset for BOOL parameters."); } - int numElements = 1; - if (matcher.group(NUM_ELEMENTS) != null) { - numElements = checkNumElements(Integer.parseInt(matcher.group(NUM_ELEMENTS)), - bytesPerString(dataType, stringLength), - dataType.name() + "(" + stringLength + ")"); - } + int[] selection = selectionOf(matcher, address); + byteOffset = resolveByteOffset(byteOffset, selection[0], bytesPerString(dataType, stringLength)); + int numElements = checkNumElements(selection[1], + bytesPerString(dataType, stringLength), + dataType.name() + "(" + stringLength + ")"); if ((transferSizeCode != null) && (dataType.getShortName() != transferSizeCode)) { throw new PlcInvalidTagException("Transfer size code '" + transferSizeCode + @@ -148,12 +158,11 @@ public static S7StringFixedLengthTag of(String address) { int blockNumber = checkDataBlockNumber(Integer.parseInt(matcher.group(BLOCK_NUMBER))); int byteOffset = checkByteOffset(Integer.parseInt(matcher.group(BYTE_OFFSET))); byte bitOffset = 0; - int numElements = 1; - if (matcher.group(NUM_ELEMENTS) != null) { - numElements = checkNumElements(Integer.parseInt(matcher.group(NUM_ELEMENTS)), - bytesPerString(dataType, stringLength), - dataType.name() + "(" + stringLength + ")"); - } + int[] selection = selectionOf(matcher, address); + byteOffset = resolveByteOffset(byteOffset, selection[0], bytesPerString(dataType, stringLength)); + int numElements = checkNumElements(selection[1], + bytesPerString(dataType, stringLength), + dataType.name() + "(" + stringLength + ")"); return new S7StringFixedLengthTag(dataType, memoryArea, blockNumber, byteOffset, bitOffset, numElements, stringLength); diff --git a/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/tag/S7StringVarLengthTag.java b/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/tag/S7StringVarLengthTag.java index 79b246774be..53566318919 100644 --- a/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/tag/S7StringVarLengthTag.java +++ b/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/tag/S7StringVarLengthTag.java @@ -32,10 +32,10 @@ public class S7StringVarLengthTag extends S7Tag { public static final Pattern DATA_BLOCK_STRING_VAR_LENGTH_ADDRESS_PATTERN = - Pattern.compile("^%DB(?\\d{1,5}).DB(?[XBWD]?)(?\\d{1,7})(.(?[0-7]))?:(?STRING|WSTRING)(\\[(?\\d{1,7})])?"); + Pattern.compile("^%DB(?\\d{1,5}).DB(?[XBWD]?)(?\\d{1,7})(.(?[0-7]))?" + ARRAY_EXPRESSION + ":(?STRING|WSTRING)"); public static final Pattern DATA_BLOCK_STRING_VAR_LENGTH_SHORT_PATTERN = - Pattern.compile("^%DB(?\\d{1,5}):(?\\d{1,7})(.(?[0-7]))?:(?STRING|WSTRING)(\\[(?\\d{1,7})])?"); + Pattern.compile("^%DB(?\\d{1,5}):(?\\d{1,7})(.(?[0-7]))?" + ARRAY_EXPRESSION + ":(?STRING|WSTRING)"); protected S7StringVarLengthTag(TransportSize dataType, MemoryArea memoryArea, @@ -103,12 +103,10 @@ public static S7StringVarLengthTag of(String address) { } else if (dataType == TransportSize.BOOL) { throw new PlcInvalidTagException("Expected bit offset for BOOL parameters."); } - int numElements = 1; - if (matcher.group(NUM_ELEMENTS) != null) { - numElements = checkNumElements(Integer.parseInt(matcher.group(NUM_ELEMENTS)), - ASSUMED_MAX_LENGTH * (dataType == TransportSize.WSTRING ? 2 : 1), - dataType.name()); - } + int[] selection = selectionOf(matcher, address); + int bytesPerElement = ASSUMED_MAX_LENGTH * (dataType == TransportSize.WSTRING ? 2 : 1); + byteOffset = resolveByteOffset(byteOffset, selection[0], bytesPerElement); + int numElements = checkNumElements(selection[1], bytesPerElement, dataType.name()); if ((transferSizeCode != null) && (dataType.getShortName() != transferSizeCode)) { throw new PlcInvalidTagException("Transfer size code '" + transferSizeCode + @@ -122,12 +120,10 @@ public static S7StringVarLengthTag of(String address) { int blockNumber = checkDataBlockNumber(Integer.parseInt(matcher.group(BLOCK_NUMBER))); int byteOffset = checkByteOffset(Integer.parseInt(matcher.group(BYTE_OFFSET))); byte bitOffset = 0; - int numElements = 1; - if (matcher.group(NUM_ELEMENTS) != null) { - numElements = checkNumElements(Integer.parseInt(matcher.group(NUM_ELEMENTS)), - ASSUMED_MAX_LENGTH * (dataType == TransportSize.WSTRING ? 2 : 1), - dataType.name()); - } + int[] selection = selectionOf(matcher, address); + int bytesPerElement = ASSUMED_MAX_LENGTH * (dataType == TransportSize.WSTRING ? 2 : 1); + byteOffset = resolveByteOffset(byteOffset, selection[0], bytesPerElement); + int numElements = checkNumElements(selection[1], bytesPerElement, dataType.name()); return new S7StringVarLengthTag(dataType, memoryArea, blockNumber, byteOffset, bitOffset, numElements); diff --git a/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/tag/S7Tag.java b/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/tag/S7Tag.java index 62d41c30f64..f7651cc4ee8 100644 --- a/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/tag/S7Tag.java +++ b/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/tag/S7Tag.java @@ -32,6 +32,8 @@ import org.apache.plc4x.java.spi.buffers.api.WriteBuffer; import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException; import org.apache.plc4x.java.spi.buffers.bytebased.ReadBufferByteBased; +import org.apache.plc4x.java.spi.drivers.model.AddressConstraints; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo; import java.nio.charset.StandardCharsets; @@ -43,16 +45,21 @@ public class S7Tag implements PlcTag, Serializable { + /** The shared array notation, which sits between the address and the type. */ + protected static final String ARRAY_EXPRESSION = ArrayNotationParser.ARRAY_GROUP; + + protected static final String ARRAY = "array"; + //byteOffset theoretically can reach up to 2097151 ... see checkByteOffset() below --> 7digits private static final Pattern ADDRESS_PATTERN = - Pattern.compile("^%(?.)(?[XBWD]?)(?\\d{1,7})(.(?[0-7]))?:(?(S5)?[a-zA-Z_]+)(\\[(?\\d{1,7})])?"); + Pattern.compile("^%(?.)(?[XBWD]?)(?\\d{1,7})(.(?[0-7]))?" + ARRAY_EXPRESSION + ":(?(S5)?[a-zA-Z_]+)"); //blockNumber usually has its max hat around 64000 --> 5digits private static final Pattern DATA_BLOCK_ADDRESS_PATTERN = - Pattern.compile("^%DB(?\\d{1,5}).DB(?[XBWD]?)(?\\d{1,7})(.(?[0-7]))?:(?(S5)?[a-zA-Z_]+)(\\[(?\\d{1,7})])?"); + Pattern.compile("^%DB(?\\d{1,5}).DB(?[XBWD]?)(?\\d{1,7})(.(?[0-7]))?" + ARRAY_EXPRESSION + ":(?(S5)?[a-zA-Z_]+)"); private static final Pattern DATA_BLOCK_SHORT_PATTERN = - Pattern.compile("^%DB(?\\d{1,5}):(?\\d{1,7})(.(?[0-7]))?:(?(S5)?[a-zA-Z_]+)(\\[(?\\d{1,7})])?"); + Pattern.compile("^%DB(?\\d{1,5}):(?\\d{1,7})(.(?[0-7]))?" + ARRAY_EXPRESSION + ":(?(S5)?[a-zA-Z_]+)"); private static final Pattern PLC_PROXY_ADDRESS_PATTERN = Pattern.compile("[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}"); @@ -64,6 +71,7 @@ public class S7Tag implements PlcTag, Serializable { protected static final String BYTE_OFFSET = "byteOffset"; protected static final String BIT_OFFSET = "bitOffset"; protected static final String NUM_ELEMENTS = "numElements"; + protected static final String MEMORY_AREA = "memoryArea"; /** highest byte offset an S7 address can name; see checkByteOffset() below */ @@ -76,9 +84,24 @@ public class S7Tag implements PlcTag, Serializable { private final byte bitOffset; private final int numElements; + /** + * Whether the address wrote the selection as a range. A one-element range is still a range, so + * the count cannot carry this: {@code [4]} and {@code [4..4]} both select one element. + */ + private final boolean explicitRange; + public S7Tag(TransportSize dataType, MemoryArea memoryArea, int blockNumber, int byteOffset, byte bitOffset, int numElements) { + this(dataType, memoryArea, blockNumber, byteOffset, bitOffset, numElements, + numElements != 1); + } + + public S7Tag(TransportSize dataType, MemoryArea memoryArea, + int blockNumber, int byteOffset, + byte bitOffset, int numElements, + boolean explicitRange) { + this.explicitRange = explicitRange; this.dataType = dataType; this.memoryArea = memoryArea; this.blockNumber = blockNumber; @@ -100,9 +123,41 @@ public S7Tag(TransportSize dataType, MemoryArea memoryArea, } + /** + * Spells the tag the way {@link #of(String)} parses it back, so a tag can be carried as a + * string - which is what a log line, a browse result or a serialized request needs. + * + *

The optional transfer size code is left out: it only repeats what the type already + * says, and an address without it parses to the same tag.

+ */ @Override public String getAddressString() { - return null; + StringBuilder sb = new StringBuilder(); + if (memoryArea == MemoryArea.DATA_BLOCKS) { + // Rendering a data block through its short name would produce "%D100", which parses + // back as block 0 of the data-block area - a different address. + sb.append("%DB").append(blockNumber).append(".DB").append(addressedByteOffset()); + } else { + sb.append('%').append(memoryArea.getShortName()).append(addressedByteOffset()); + } + // A bit offset is only part of an address for BOOL, and is required there. + if (dataType == TransportSize.BOOL) { + sb.append('.').append(bitOffset); + } + sb.append(ArrayNotationParser.render(getArrayInfo())); + return sb.append(':').append(dataType.name()).toString(); + } + + /** + * The byte offset as the address writes it. A COUNTER address names a counter, which the + * constructor splits across the byte and bit offsets, so rendering one has to put it back + * together - otherwise "%DB1.DB100:COUNTER" comes back as counter 12. + */ + protected int addressedByteOffset() { + if (dataType == TransportSize.COUNTER) { + return (byteOffset << 3) | bitOffset; + } + return byteOffset; } @Override @@ -124,12 +179,19 @@ public PlcValueType getPlcValueType() { @Override public List getArrayInfo() { - if (numElements != 1) { - return Collections.singletonList(new DefaultArrayInfo(0, numElements - 1)); + // The flag decides the shape and the count only sizes it: a range is an array even when it + // spans one element, which no count can express. + if (explicitRange) { + return Collections.singletonList(new DefaultArrayInfo(0, numElements - 1, 0, true)); } return Collections.emptyList(); } + /** Whether the address wrote a range, as opposed to selecting a single element. */ + public boolean isExplicitRange() { + return explicitRange; + } + public TransportSize getDataType() { return dataType; } @@ -195,17 +257,16 @@ public static S7Tag of(String tagString) { } else if (dataType == TransportSize.BOOL) { throw new PlcInvalidTagException("Expected bit offset for BOOL parameters."); } - int numElements = 1; - if (matcher.group(NUM_ELEMENTS) != null) { - numElements = checkNumElements(dataType, Integer.parseInt(matcher.group(NUM_ELEMENTS))); - } + int[] selection = selectionOf(matcher, tagString); + byteOffset = resolveByteOffset(byteOffset, selection[0], dataType.getSizeInBytes()); + int numElements = checkNumElements(dataType, selection[1]); if ((transferSizeCode != null) && (dataType.getShortName() != transferSizeCode)) { throw new PlcInvalidTagException("Transfer size code '" + transferSizeCode + "' doesn't match specified data type '" + dataType.name() + "'"); } - return new S7Tag(dataType, memoryArea, blockNumber, byteOffset, bitOffset, numElements); + return new S7Tag(dataType, memoryArea, blockNumber, byteOffset, bitOffset, numElements, selection[2] == 1); } else if ((matcher = DATA_BLOCK_SHORT_PATTERN.matcher(tagString)).matches()) { String dataTypeName = matcher.group(DATA_TYPE); if("RAW_BYTE_ARRAY".equals(dataTypeName)) { @@ -221,12 +282,11 @@ public static S7Tag of(String tagString) { } else if (dataType == TransportSize.BOOL) { throw new PlcInvalidTagException("Expected bit offset for BOOL parameters."); } - int numElements = 1; - if (matcher.group(NUM_ELEMENTS) != null) { - numElements = checkNumElements(dataType, Integer.parseInt(matcher.group(NUM_ELEMENTS))); - } + int[] selection = selectionOf(matcher, tagString); + byteOffset = resolveByteOffset(byteOffset, selection[0], dataType.getSizeInBytes()); + int numElements = checkNumElements(dataType, selection[1]); - return new S7Tag(dataType, memoryArea, blockNumber, byteOffset, bitOffset, numElements); + return new S7Tag(dataType, memoryArea, blockNumber, byteOffset, bitOffset, numElements, selection[2] == 1); } else if (PLC_PROXY_ADDRESS_PATTERN.matcher(tagString).matches()) { try { String hex = tagString.replace("-", ""); @@ -261,10 +321,9 @@ public static S7Tag of(String tagString) { } else if (dataType == TransportSize.BOOL) { throw new PlcInvalidTagException("Expected bit offset for BOOL parameters."); } - int numElements = 1; - if (matcher.group(NUM_ELEMENTS) != null) { - numElements = checkNumElements(dataType, Integer.parseInt(matcher.group(NUM_ELEMENTS))); - } + int[] selection = selectionOf(matcher, tagString); + byteOffset = resolveByteOffset(byteOffset, selection[0], dataType.getSizeInBytes()); + int numElements = checkNumElements(dataType, selection[1]); if ((transferSizeCode != null) && (dataType.getShortName() != transferSizeCode)) { throw new PlcInvalidTagException("Transfer size code '" + transferSizeCode + @@ -274,9 +333,10 @@ public static S7Tag of(String tagString) { throw new PlcInvalidTagException("A bit offset other than 0 is only supported for type BOOL"); } - return new S7Tag(dataType, memoryArea, (short) 0, byteOffset, bitOffset, numElements); + return new S7Tag(dataType, memoryArea, (short) 0, byteOffset, bitOffset, numElements, selection[2] == 1); } - throw new PlcInvalidTagException("Unable to parse address: " + tagString); + throw ArrayNotationParser.invalidAddress(tagString, + "%{area}{offset}[selection]:{TYPE} - for example %DB42:28.0[0..3]:BYTE"); } /** @@ -304,6 +364,26 @@ protected static int checkDataBlockNumber(int blockNumber) { * @param numElements given number of elements * @return given numElements if Ok, throws PlcInvalidTagException otherwise */ + /** + * Resolves the address's array expression to the offset of the first element and the number + * of elements. An absent expression selects one element at the address itself. + * + *

The offset is in elements; a caller scales it by the data type's size to reach a byte + * offset. + * + * @return {@code {elementOffset, numElements}} + */ + protected static int[] selectionOf(Matcher matcher, String address) { + String expression = matcher.group(ARRAY); + if (expression == null) { + return new int[]{0, 1, 0}; + } + ArrayInfo dimension = ArrayNotationParser + .parse(expression, address, AddressConstraints.SINGLE_DIMENSION).get(0); + return new int[]{dimension.getLowerBound() - dimension.getBase(), dimension.getSize(), + dimension.isRange() ? 1 : 0}; + } + protected static int checkNumElements(TransportSize dataType, int numElements) { return checkNumElements(numElements, dataType.getSizeInBytes(), dataType.name()); } @@ -337,6 +417,24 @@ protected static int checkNumElements(int numElements, int bytesPerElement, Stri * @param byteOffset given byteOffset * @return given byteOffset if Ok, throws PlcInvalidTagException otherwise */ + /** + * The byte offset a selection resolves to: the written offset plus the selection's start, + * scaled by what one element costs. + * + *

The parser accepts indices up to {@link Integer#MAX_VALUE} - it cannot know what a + * driver's elements cost - so the scaling has to happen in {@code long} and be checked before + * it is narrowed. In {@code int} a large index wrapped into a small, apparently valid offset, + * and the tag then read a different location without complaint.

+ */ + protected static int resolveByteOffset(int byteOffset, int elementOffset, int bytesPerElement) { + long resolved = (long) byteOffset + (long) elementOffset * bytesPerElement; + if ((resolved > MAX_BYTE_OFFSET) || (resolved < 0)) { + throw new PlcInvalidTagException("ByteOffset must be smaller than " + MAX_BYTE_OFFSET + + " and positive. The selection resolves to " + resolved); + } + return (int) resolved; + } + protected static int checkByteOffset(int byteOffset) { // TODO: check the value or add reference if (byteOffset > MAX_BYTE_OFFSET || byteOffset < 0) { @@ -380,12 +478,12 @@ public String toString() { public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; S7Tag s7Tag = (S7Tag) o; - return blockNumber == s7Tag.blockNumber && byteOffset == s7Tag.byteOffset && bitOffset == s7Tag.bitOffset && numElements == s7Tag.numElements && dataType == s7Tag.dataType && memoryArea == s7Tag.memoryArea; + return blockNumber == s7Tag.blockNumber && byteOffset == s7Tag.byteOffset && bitOffset == s7Tag.bitOffset && numElements == s7Tag.numElements && explicitRange == s7Tag.explicitRange && dataType == s7Tag.dataType && memoryArea == s7Tag.memoryArea; } @Override public int hashCode() { - return Objects.hash(dataType, memoryArea, blockNumber, byteOffset, bitOffset, numElements); + return Objects.hash(dataType, memoryArea, blockNumber, byteOffset, bitOffset, numElements, explicitRange); } @Override diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualFactoryS71200DriverTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualFactoryS71200DriverTest.java index 08bd67afdfe..b6f29687bea 100644 --- a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualFactoryS71200DriverTest.java +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualFactoryS71200DriverTest.java @@ -95,10 +95,10 @@ public static void main(String[] args) throws Exception { test.addTestCase("%DB4:58:TIME", new PlcTIME(Duration.parse("PT1.234S"))); test.addTestCase("%DB4:70:DATE", new PlcDATE(LocalDate.parse("1998-03-28"))); test.addTestCase("%DB4:72:TIME_OF_DAY", new PlcTIME_OF_DAY(LocalTime.parse("15:36:30.123"))); - test.addTestCase("%DB4:908:CHAR[5]", new PlcList(Arrays.asList(new PlcCHAR("w"), new PlcCHAR("i"), new PlcCHAR("e"), new PlcCHAR("s"), new PlcCHAR("e")))); - test.addTestCase("%DB4:914:RAW_BYTE_ARRAY[11]", new PlcRawByteArray(new byte[] {(byte) 0, (byte) 1, (byte) 2, (byte) 3, (byte) 4, (byte) 5, (byte) 6, (byte) 7, (byte) 8, (byte) 9, (byte) 10})); + test.addTestCase("%DB4:908[0..4]:CHAR", new PlcList(Arrays.asList(new PlcCHAR("w"), new PlcCHAR("i"), new PlcCHAR("e"), new PlcCHAR("s"), new PlcCHAR("e")))); + test.addTestCase("%DB4:914[0..10]:RAW_BYTE_ARRAY", new PlcRawByteArray(new byte[] {(byte) 0, (byte) 1, (byte) 2, (byte) 3, (byte) 4, (byte) 5, (byte) 6, (byte) 7, (byte) 8, (byte) 9, (byte) 10})); // Disabled, as we currently only have the large-array-splitting for read requests. - /*test.addTestCase("%DB4:926:DINT[100]", new PlcList(Arrays.asList(new PlcDINT(1), new PlcDINT(2), new PlcDINT(3), new PlcDINT(4), new PlcDINT(5), new PlcDINT(6), new PlcDINT(7), new PlcDINT(8), new PlcDINT(9), + /*test.addTestCase("%DB4:926[0..99]:DINT", new PlcList(Arrays.asList(new PlcDINT(1), new PlcDINT(2), new PlcDINT(3), new PlcDINT(4), new PlcDINT(5), new PlcDINT(6), new PlcDINT(7), new PlcDINT(8), new PlcDINT(9), new PlcDINT(0), new PlcDINT(1), new PlcDINT(2), new PlcDINT(3), new PlcDINT(4), new PlcDINT(5), new PlcDINT(6), new PlcDINT(7), new PlcDINT(8), new PlcDINT(9), new PlcDINT(0), new PlcDINT(1), new PlcDINT(2), new PlcDINT(3), new PlcDINT(4), new PlcDINT(5), new PlcDINT(6), new PlcDINT(7), new PlcDINT(8), new PlcDINT(9), new PlcDINT(0), new PlcDINT(1), new PlcDINT(2), new PlcDINT(3), new PlcDINT(4), new PlcDINT(5), new PlcDINT(6), new PlcDINT(7), new PlcDINT(8), new PlcDINT(9), diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS7300DriverTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS7300DriverTest.java index ffd9a1b4164..46c73fd3709 100644 --- a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS7300DriverTest.java +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS7300DriverTest.java @@ -58,24 +58,24 @@ public static void main(String[] args) throws Exception { test.addTestCase(/*"g_dattim",*/ "%DB42:28.0:DATE_AND_TIME", new PlcDATE_AND_TIME(LocalDateTime.of(2025, 11, 12, 14, 33, 21))); test.addTestCase(/*"g_str",*/ "%DB42:36.0:STRING(40)", new PlcSTRING("Hello PLC4X")); if(testArrays) { - test.addTestCase(/*"g_arrBool",*/ "%DB42:78.0:BOOL[8]", new PlcList(List.of( + test.addTestCase(/*"g_arrBool",*/ "%DB42:78.0[0..7]:BOOL", new PlcList(List.of( new PlcBOOL(true), new PlcBOOL(false), new PlcBOOL(true), new PlcBOOL(false), new PlcBOOL(false), new PlcBOOL(false), new PlcBOOL(true), new PlcBOOL(false)) )); - test.addTestCase(/*"g_arrByte",*/ "%DB42:80.0:BYTE[8]", new PlcList(List.of( + test.addTestCase(/*"g_arrByte",*/ "%DB42:80.0[0..7]:BYTE", new PlcList(List.of( new PlcBYTE(0xDE), new PlcBYTE(0xAD), new PlcBYTE(0xBE), new PlcBYTE(0xEF), new PlcBYTE(0x12), new PlcBYTE(0x34), new PlcBYTE(0x56), new PlcBYTE(0x78)) )); - test.addTestCase(/*"g_arrInt",*/ "%DB4:88.0:INT[5]", new PlcList(List.of( + test.addTestCase(/*"g_arrInt",*/ "%DB4:88.0[0..4]:INT", new PlcList(List.of( new PlcINT(-3), new PlcINT(-1), new PlcINT(0), new PlcINT(1), new PlcINT(3)) )); - test.addTestCase(/*"g_arrDInt",*/ "%DB42:98.0:DINT[4]", new PlcList(List.of( + test.addTestCase(/*"g_arrDInt",*/ "%DB42:98.0[0..3]:DINT", new PlcList(List.of( new PlcDINT(-1000), new PlcDINT(0), new PlcDINT(1000), new PlcDINT(2000000)) )); - test.addTestCase(/*"g_arrTime",*/ "%DB42:114.0:TIME[3]", new PlcList(List.of( + test.addTestCase(/*"g_arrTime",*/ "%DB42:114.0[0..2]:TIME", new PlcList(List.of( new PlcTIME(Duration.ofMillis(10)), new PlcTIME(Duration.ofSeconds(1)), new PlcTIME(Duration.ofSeconds(10))) )); - test.addTestCase(/*"g_arrString",*/ "%DB42:126.0:STRING(16)[3]", new PlcList(List.of( + test.addTestCase(/*"g_arrString",*/ "%DB42:126.0[0..2]:STRING(16)", new PlcList(List.of( new PlcSTRING("alpha"), new PlcSTRING("beta"), new PlcSTRING("gamma")) )); test.addTestCase(/*"g_matI16_2x3",*/ "%DB42:180.0:INT[2][3]", new PlcList(List.of( diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/optimizer/S7BlockReadOptimizerTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/optimizer/S7BlockReadOptimizerTest.java index 87ffe6506ab..676f2b07342 100644 --- a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/optimizer/S7BlockReadOptimizerTest.java +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/optimizer/S7BlockReadOptimizerTest.java @@ -124,7 +124,7 @@ void singleReadLargerThanPduIsSplit() { S7DriverContext tiny = new S7DriverContext(); tiny.setPduSize(64); // A 200-byte array is larger than the PDU's available payload so it must be split. - PlcReadRequest r = req(new String[][] {{"big", "%DB1.DBB0:BYTE[200]"}}); + PlcReadRequest r = req(new String[][] {{"big", "%DB1.DBB0[0..199]:BYTE"}}); List chunks = optimizer.splitReadRequest(r, tiny); assertTrue(chunks.size() > 1, "expected multiple chunks, got " + chunks.size()); // Each chunk's binding flags as a split fragment of the same original tag. diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/optimizer/S7OptimizerTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/optimizer/S7OptimizerTest.java index 899855cbbf0..7b6a7f9eb6e 100644 --- a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/optimizer/S7OptimizerTest.java +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/optimizer/S7OptimizerTest.java @@ -102,7 +102,7 @@ void splitWriteBoolAndString() { void splitWriteByteArrayCheckedSize() { LinkedHashMap> tags = new LinkedHashMap<>(); tags.put("buf", new DefaultPlcTagValueItem<>( - S7Tag.of("%DB1.DBB0:BYTE[8]"), new PlcRawByteArray(new byte[]{1, 2, 3, 4, 5, 6, 7, 8}))); + S7Tag.of("%DB1.DBB0[0..7]:BYTE"), new PlcRawByteArray(new byte[]{1, 2, 3, 4, 5, 6, 7, 8}))); PlcWriteRequest req = new DefaultPlcWriteRequest(null, tags); List chunks = optimizer.splitWriteRequest(req, context); assertEquals(1, chunks.size()); diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7AddressStringTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7AddressStringTest.java new file mode 100644 index 00000000000..da2f28c2270 --- /dev/null +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7AddressStringTest.java @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.s7.tag; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * An S7 tag spells itself the way {@link S7Tag#of(String)} reads it back. + * + *

{@code getAddressString()} used to return {@code null}, so anything carrying a tag as a + * string - a log line, a browse result, a serialized request - got nothing at all from an S7 + * tag.

+ */ +class S7AddressStringTest { + + @ParameterizedTest + @ValueSource(strings = { + "%M100:INT", + "%M100[0..9]:INT", + "%Q0.0:BOOL", + "%I7.3:BOOL", + "%DB1.DB20:INT", + "%DB1.DB20[0..3]:INT", + "%DB1.DB0.0:BOOL", + "%DB42.DB28[0..7]:BYTE", + "%DB1.DB0:STRING(20)", + "%DB1.DB0[0..2]:STRING(20)", + "%DB1.DB0:WSTRING(10)", + "%DB69.DB68:STRING", + }) + void anAddressRendersAsItselfAndParsesBack(String address) { + S7Tag tag = S7Tag.of(address); + + assertEquals(address, tag.getAddressString(), "rendered form"); + assertEquals(tag, S7Tag.of(tag.getAddressString()), "re-parsed tag"); + } + + /** + * A COUNTER address names a counter, which the constructor splits across the byte and bit + * offsets. Rendering has to put it back together, or the address comes back naming a + * different counter - 100 stored as byte 12 bit 4 would render as counter 12. + */ + @Test + void aCounterAddressSurvivesTheSplitItIsStoredIn() { + S7Tag counter = S7Tag.of("%DB1.DB100:COUNTER"); + + assertEquals("%DB1.DB100:COUNTER", counter.getAddressString()); + assertEquals(counter, S7Tag.of(counter.getAddressString())); + } + + /** + * The optional transfer size code only repeats what the type already says, so it is not part + * of the canonical form. The address it renders to still parses to the same tag. + */ + @Test + void theTransferSizeCodeIsNotPartOfTheCanonicalForm() { + S7Tag tag = S7Tag.of("%DB69.DBX68[0..2]:WSTRING(254)"); + + assertEquals("%DB69.DB68[0..2]:WSTRING(254)", tag.getAddressString()); + assertEquals(tag, S7Tag.of(tag.getAddressString())); + } + + /** + * A data block is rendered as a data block. Spelling it through the memory area's short name + * would produce "%D100", which parses back as block 0 of the data-block area - a different + * address that reads different memory. + */ + @Test + void aDataBlockIsNotRenderedThroughItsShortName() { + S7Tag tag = S7Tag.of("%DB42.DB28:BYTE"); + + assertEquals("%DB42.DB28:BYTE", tag.getAddressString()); + assertEquals(42, S7Tag.of(tag.getAddressString()).getBlockNumber()); + } + + /** A declared base is resolved into the address, so what renders back is the resolved one. */ + @Test + void aDeclaredBaseIsResolvedIntoTheRenderedAddress() { + S7Tag tag = S7Tag.of("%DB1.DB20[4..7;4]:INT"); + + assertEquals("%DB1.DB20[0..3]:INT", tag.getAddressString()); + assertEquals(tag, S7Tag.of(tag.getAddressString())); + } +} diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7ArrayParityTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7ArrayParityTest.java new file mode 100644 index 00000000000..79252cefefe --- /dev/null +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7ArrayParityTest.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.s7.tag; + +import org.apache.plc4x.java.api.model.ArrayInfo; +import org.apache.plc4x.java.s7.tag.S7Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The same selection means the same thing here as on every other driver (SC-001). + * + *

Each driver asserts this against its own address syntax; the assertions are deliberately + * identical, because that sameness is the success criterion. + */ +class S7ArrayParityTest { + + @Test + void aRangeOfEightStartingAtZero() { + List dimensions = S7Tag.of("%DB42:28.0[0..7]:BYTE").getArrayInfo(); + + assertEquals(1, dimensions.size(), "one dimension"); + assertEquals(8, dimensions.get(0).getSize(), "eight elements"); + assertEquals(0, dimensions.get(0).getLowerBound() - dimensions.get(0).getBase(), + "starting at offset zero"); + assertTrue(dimensions.get(0).isRange(), "written as a range"); + } + + @Test + void aBareIndexIsAScalar() { + assertTrue(S7Tag.of("%DB42:28.0[4]:BYTE").getArrayInfo().isEmpty()); + } + + /** + * The case the notation exists to distinguish: a range spanning one element is an array of + * one, while a bare index is a scalar. No element count can tell them apart, so a driver that + * derives its shape from the count alone silently collapses them - which is how the SLMP tag + * reported a one-element range as a scalar while plc4go's reported a list. + */ + @Test + void aOneElementRangeIsAnArrayOfOne() { + List dimensions = S7Tag.of("%DB42:28.0[4..4]:BYTE").getArrayInfo(); + + assertEquals(1, dimensions.size(), "one dimension"); + assertEquals(1, dimensions.get(0).getSize(), "one element"); + assertTrue(dimensions.get(0).isRange(), "written as a range"); + + assertTrue(S7Tag.of("%DB42:28.0[4]:BYTE").getArrayInfo().isEmpty(), + "while the bare index of the same element stays a scalar"); + } + + @Test + void anOmittedSelectionIsAScalar() { + assertTrue(S7Tag.of("%DB42:28.0:BYTE").getArrayInfo().isEmpty()); + } +} diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7DeclaredBaseTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7DeclaredBaseTest.java new file mode 100644 index 00000000000..dd1b36e941d --- /dev/null +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7DeclaredBaseTest.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.s7.tag; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.api.model.ArrayInfo; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Addressing an array that does not start at zero. + * + *

A TIA declaration of {@code ARRAY[1..10] OF BYTE} at {@code %DB42:28.0} is addressed with + * the indices the PLC program shows, and the declared lower bound tells the driver where the + * data really begins. The written indices are kept as written; the offset is derived. + */ +class S7DeclaredBaseTest { + + /** + * S7 addresses a memory offset, so the declared base is consumed while parsing: the byte + * offset moves and the reported selection is zero-based. The base has no meaning once it has + * been applied - see FR-014. + */ + @Test + void theBaseIsConsumedAndTheReportedSelectionIsZeroBased() { + S7Tag tag = S7Tag.of("%DB42:28.0[4..7;1]:BYTE"); + + ArrayInfo dimension = tag.getArrayInfo().get(0); + assertEquals(0, dimension.getLowerBound(), "normalised once the base was applied"); + assertEquals(3, dimension.getUpperBound()); + assertEquals(4, dimension.getSize(), "still four elements"); + assertEquals(31, tag.getByteOffset(), "the base moved the offset instead"); + } + + /** The base moves where the read starts: element 4 of an array declared from 1 is the fourth. */ + @Test + void theDeclaredBaseMovesTheByteOffset() { + assertEquals(28, S7Tag.of("%DB42:28.0[1..4;1]:BYTE").getByteOffset(), "starts at the array"); + assertEquals(31, S7Tag.of("%DB42:28.0[4..7;1]:BYTE").getByteOffset(), "three BYTEs in"); + assertEquals(28, S7Tag.of("%DB42:28.0[1;1]:BYTE").getByteOffset(), "the first element"); + } + + /** Element size scales the offset - a WORD is two bytes, so the same index moves twice as far. */ + @Test + void theOffsetScalesWithTheElementSize() { + assertEquals(28 + 3 * 2, S7Tag.of("%DB42:28.0[4..7;1]:WORD").getByteOffset()); + } + + @Test + void anIndexBelowTheDeclaredBaseIsRejected() { + assertThrows(PlcInvalidTagException.class, () -> S7Tag.of("%DB42:28.0[0;1]:BYTE")); + } + + /** Without a declared base the indices are zero-based, so nothing moves. */ + @Test + void withoutADeclaredBaseTheIndicesAreZeroBased() { + assertEquals(28, S7Tag.of("%DB42:28.0[0..3]:BYTE").getByteOffset()); + assertEquals(0, S7Tag.of("%DB42:28.0[0..3]:BYTE").getArrayInfo().get(0).getBase()); + } +} diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7LegacyAddressTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7LegacyAddressTest.java new file mode 100644 index 00000000000..7814f1ddcda --- /dev/null +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7LegacyAddressTest.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.s7.tag; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.s7.tag.S7Tag; +import org.apache.plc4x.java.s7.tag.S7StringFixedLengthTag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Addresses written before the array notation was unified must not parse. + * + *

The brackets moved from after the type to before it, which is what turns an otherwise + * silent change of meaning into a failure: {@code [4]} used to mean four elements and now means + * the fifth, so an unmodified address that still parsed would quietly return different data. + */ +class S7LegacyAddressTest { + + @Test + void legacyForm0IsRejected() { + assertThrows(PlcInvalidTagException.class, () -> S7Tag.of("%DB42:28.0:BYTE[4]")); + } + + @Test + void legacyForm1IsRejected() { + assertThrows(PlcInvalidTagException.class, () -> S7Tag.of("%DB1.DBW20:INT[5]")); + } + + @Test + void legacyForm2IsRejected() { + assertThrows(PlcInvalidTagException.class, () -> S7StringFixedLengthTag.of("%DB1.DB0:STRING(40)[3]")); + } + + @Test + void theReplacementFormParses() { + assertNotNull(S7Tag.of("%DB42:28.0[0..3]:BYTE")); + } + + /** The message has to hand an upgrading user the address to write, not just a regex. */ + @Test + void theErrorNamesTheReplacementAddress() { + PlcInvalidTagException thrown = + assertThrows(PlcInvalidTagException.class, () -> S7Tag.of("%DB42:28.0:BYTE[4]")); + assertTrue(thrown.getMessage().contains("%DB42:28.0[0..3]:BYTE"), thrown::getMessage); + } +} diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7StringTagTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7StringTagTest.java index d4788e3a0eb..d8ffcc277c8 100644 --- a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7StringTagTest.java +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7StringTagTest.java @@ -38,7 +38,7 @@ void parseFixedLengthString() { @Test void parseFixedLengthStringWithCount() { - S7StringFixedLengthTag tag = S7StringFixedLengthTag.of("%DB1.DB0:STRING(40)[3]"); + S7StringFixedLengthTag tag = S7StringFixedLengthTag.of("%DB1.DB0[0..2]:STRING(40)"); assertNotNull(tag); assertEquals(40, tag.getStringLength()); assertEquals(3, tag.getNumberOfElements()); @@ -128,7 +128,7 @@ void s7TagOfParsesFixedLengthStrings() { assertEquals(69, shortForm.getBlockNumber()); assertEquals(68, shortForm.getByteOffset()); - S7Tag withCount = S7Tag.of("%DB1.DB0:WSTRING(40)[3]"); + S7Tag withCount = S7Tag.of("%DB1.DB0[0..2]:WSTRING(40)"); assertInstanceOf(S7StringFixedLengthTag.class, withCount); assertEquals(40, ((S7StringFixedLengthTag) withCount).getStringLength()); assertEquals(3, withCount.getNumberOfElements()); @@ -137,7 +137,7 @@ void s7TagOfParsesFixedLengthStrings() { @Test void s7TagMatchesAcceptsStringLengths() { assertTrue(S7Tag.matches("%DB69:68:STRING(20)")); - assertTrue(S7Tag.matches("%DB1.DB0:WSTRING(40)[3]")); + assertTrue(S7Tag.matches("%DB1.DB0[0..2]:WSTRING(40)")); assertFalse(S7Tag.matches("not-a-tag")); } @@ -150,7 +150,7 @@ void s7TagOfAgreesWithTagHandler() { for (String address : new String[]{ "%DB69:68:STRING(20)", "%DB1.DB0:STRING(80)", - "%DB1.DB0:STRING(40)[3]", + "%DB1.DB0[0..2]:STRING(40)", "%DB1.DB0:STRING", "%DB1:0:STRING", "%MW0:INT", diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7TagElementCountBoundTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7TagElementCountBoundTest.java index 23bac4d0bae..d0d5264a3c7 100644 --- a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7TagElementCountBoundTest.java +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7TagElementCountBoundTest.java @@ -32,18 +32,43 @@ public class S7TagElementCountBoundTest { @Test void aCountTooWideToBeANumberIsAnInvalidTagNotANumberFormatError() { - assertThrows(PlcInvalidTagException.class, () -> S7Tag.of("%DB1:0:INT[99999999999]")); + assertThrows(PlcInvalidTagException.class, () -> S7Tag.of("%DB1:0[0..99999999998]:INT")); } @Test void aCountSpanningMoreThanTheAddressableAreaIsRejected() { // Two million LREALs are sixteen megabytes, and no S7 area is that large. - assertThrows(PlcInvalidTagException.class, () -> S7Tag.of("%DB1:0:LREAL[2000000]")); + assertThrows(PlcInvalidTagException.class, () -> S7Tag.of("%DB1:0[0..1999999]:LREAL")); } @Test void theSameCountOfSingleBytesIsWithinTheAreaAndStillParses() { - assertEquals(2000000, S7Tag.of("%DB1:0:BYTE[2000000]").getNumberOfElements()); + assertEquals(2000000, S7Tag.of("%DB1:0[0..1999999]:BYTE").getNumberOfElements()); + } + + /** + * A start index is scaled by what one element costs, and the parser accepts indices up to + * Integer.MAX_VALUE. Scaled in int, a large one wrapped into a small offset that looked + * perfectly valid, and the tag then read a different location without complaint. + */ + @Test + void aSelectionOffsetThatOverflowsIsRejectedRatherThanWrapped() { + assertThrows(PlcInvalidTagException.class, () -> S7Tag.of("%DB1:0[268435456]:LREAL")); + assertThrows(PlcInvalidTagException.class, () -> S7Tag.of("%DB1.DBW0[536870912]:LREAL")); + } + + /** + * The same scaling without an overflow still has to land inside the addressable area - it was + * not checked at all after the selection was applied. + */ + @Test + void aSelectionOffsetPastTheAddressableAreaIsRejected() { + assertThrows(PlcInvalidTagException.class, () -> S7Tag.of("%DB1:0[1000000]:LREAL")); + } + + @Test + void aSelectionOffsetInsideTheAddressableAreaStillParses() { + assertEquals(800, S7Tag.of("%DB1:0[100]:LREAL").getByteOffset()); } @Test @@ -51,30 +76,30 @@ void aFixedLengthStringCountIsBoundedByWhatOneStringCosts() { // 9999 strings of 254 characters plus their two length bytes is past the area; the // optimizer would have multiplied that out in an int before anybody looked at it. assertThrows(PlcInvalidTagException.class, - () -> S7StringFixedLengthTag.of("%DB1:0:STRING(254)[9999]")); + () -> S7StringFixedLengthTag.of("%DB1:0[0..9998]:STRING(254)")); } @Test void aFixedLengthStringCountThatFitsStillParses() { - assertEquals(8000, S7StringFixedLengthTag.of("%DB1:0:STRING(254)[8000]").getNumberOfElements()); + assertEquals(8000, S7StringFixedLengthTag.of("%DB1:0[0..7999]:STRING(254)").getNumberOfElements()); } @Test void aVarLengthStringCountIsBoundedByTheLengthTheDriverAssumes() { assertThrows(PlcInvalidTagException.class, - () -> S7StringVarLengthTag.of("%DB1:0:STRING[9999]")); + () -> S7StringVarLengthTag.of("%DB1:0[0..9998]:STRING")); } @Test void aWideStringCostsTwiceAsMuchPerElement() { // The same count that fits as STRING does not fit as WSTRING. - assertEquals(8000, S7StringFixedLengthTag.of("%DB1:0:STRING(254)[8000]").getNumberOfElements()); + assertEquals(8000, S7StringFixedLengthTag.of("%DB1:0[0..7999]:STRING(254)").getNumberOfElements()); assertThrows(PlcInvalidTagException.class, - () -> S7StringFixedLengthTag.of("%DB1:0:WSTRING(254)[8000]")); + () -> S7StringFixedLengthTag.of("%DB1:0[0..7999]:WSTRING(254)")); } @Test void aPlausibleCountStillParses() { - assertEquals(64, S7Tag.of("%DB1:0:INT[64]").getNumberOfElements()); + assertEquals(64, S7Tag.of("%DB1:0[0..63]:INT").getNumberOfElements()); } } diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7TagSerializeTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7TagSerializeTest.java index d8fe37843da..48b5d7cd72e 100644 --- a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7TagSerializeTest.java +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7TagSerializeTest.java @@ -49,7 +49,7 @@ void s7Tag_serializeProducesNonEmptyOutput() throws Exception { @Test void s7Tag_serializeWithArrayCount() throws Exception { - S7Tag tag = S7Tag.of("%DB1.DBW20:INT[5]"); + S7Tag tag = S7Tag.of("%DB1.DBW20[0..4]:INT"); WriteBufferByteBased buf = buffer(); tag.serialize(buf); assertEquals(5, tag.getNumberOfElements()); @@ -77,7 +77,7 @@ void s7Tag_equalsAndHashCode() { @Test void s7Tag_toStringMentionsDataTypeAndArea() { - S7Tag t = S7Tag.of("%DB10.DBD8:DINT[3]"); + S7Tag t = S7Tag.of("%DB10.DBD8[0..2]:DINT"); String s = t.toString(); assertTrue(s.contains("DINT"), () -> "expected dataType in toString: " + s); assertTrue(s.contains("DATA_BLOCKS") || s.contains("blockNumber"), @@ -101,7 +101,7 @@ void s7StringFixedLengthTag_wstring() { @Test void s7StringFixedLengthTag_array() { - S7StringFixedLengthTag tag = S7StringFixedLengthTag.of("%DB1.DB0:STRING(40)[3]"); + S7StringFixedLengthTag tag = S7StringFixedLengthTag.of("%DB1.DB0[0..2]:STRING(40)"); assertEquals(40, tag.getStringLength()); assertEquals(3, tag.getNumberOfElements()); } diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7TagTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7TagTest.java index 31cd1cb7e39..e49c99257bb 100644 --- a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7TagTest.java +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/tag/S7TagTest.java @@ -66,7 +66,7 @@ void parseFlagDword() { @Test void parseDataBlockByteArray() { - S7Tag tag = S7Tag.of("%DB1.DBB0:BYTE[10]"); + S7Tag tag = S7Tag.of("%DB1.DBB0[0..9]:BYTE"); assertEquals(TransportSize.BYTE, tag.getDataType()); assertEquals(10, tag.getNumberOfElements()); } @@ -108,7 +108,7 @@ void parseDataBlockShortForm() { @Test void parseRawByteArrayKeyword() { - S7Tag tag = S7Tag.of("%DB1.DBB0:RAW_BYTE_ARRAY[16]"); + S7Tag tag = S7Tag.of("%DB1.DBB0[0..15]:RAW_BYTE_ARRAY"); assertEquals(TransportSize.BYTE, tag.getDataType()); assertEquals(16, tag.getNumberOfElements()); } @@ -127,7 +127,7 @@ void plcValueTypeMappings() { void arrayInfoNonScalar() { S7Tag scalar = S7Tag.of("%MW0:INT"); assertTrue(scalar.getArrayInfo().isEmpty()); - S7Tag arr = S7Tag.of("%DB1.DBB0:BYTE[10]"); + S7Tag arr = S7Tag.of("%DB1.DBB0[0..9]:BYTE"); assertEquals(1, arr.getArrayInfo().size()); assertEquals(0, arr.getArrayInfo().get(0).getLowerBound()); assertEquals(9, arr.getArrayInfo().get(0).getUpperBound()); diff --git a/plc4j/drivers/simulated/src/main/java/org/apache/plc4x/java/simulated/tag/SimulatedTag.java b/plc4j/drivers/simulated/src/main/java/org/apache/plc4x/java/simulated/tag/SimulatedTag.java index e64f4ea1257..dc872918293 100644 --- a/plc4j/drivers/simulated/src/main/java/org/apache/plc4x/java/simulated/tag/SimulatedTag.java +++ b/plc4j/drivers/simulated/src/main/java/org/apache/plc4x/java/simulated/tag/SimulatedTag.java @@ -24,6 +24,8 @@ import org.apache.plc4x.java.api.types.PlcValueType; import org.apache.plc4x.java.simulated.readwrite.SimulatedDataTypeSizes; import org.apache.plc4x.java.simulated.types.SimulatedTagType; +import org.apache.plc4x.java.spi.drivers.model.AddressConstraints; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo; import java.util.Collections; @@ -43,7 +45,7 @@ public class SimulatedTag implements PlcTag { * - {@code STDOUT/foo:STRING} */ private static final Pattern ADDRESS_PATTERN = Pattern.compile( - "^(?\\w+)/(?[a-zA-Z0-9_\\\\.]+):(?[a-zA-Z0-9]++)(\\[(?\\d{1,9})])?$"); + "^(?\\w+)/(?[a-zA-Z0-9_\\\\.]+)" + ArrayNotationParser.ARRAY_GROUP + ":(?[a-zA-Z0-9]++)$"); /** * Largest amount of made-up data a single tag may ask for. @@ -59,11 +61,54 @@ public class SimulatedTag implements PlcTag { private final PlcValueType dataType; private final int numElements; + /** + * Whether the address wrote the selection as a range. {@code [0]} and {@code [0..0]} both make + * one element, and only the range is an array, so the count cannot carry this. + */ + private final boolean explicitRange; + private SimulatedTag(SimulatedTagType type, String name, PlcValueType dataType, int numElements) { + this(type, name, dataType, numElements, numElements > 1); + } + + private SimulatedTag(SimulatedTagType type, String name, PlcValueType dataType, int numElements, + boolean explicitRange) { this.type = type; this.name = name; this.dataType = dataType; this.numElements = numElements; + this.explicitRange = explicitRange; + } + + + /** + * Resolves the address's array expression to the number of elements. This driver addresses a + * named variable rather than a numeric offset, so a selection that does not start at the + * first element has nothing to apply to and is reported rather than quietly ignored. + */ + /** Whether the address wrote a range. Not derivable from the count - see {@link #explicitRange}. */ + private static boolean rangeWritten(Matcher matcher, String tagString) { + String expression = matcher.group("array"); + if (expression == null) { + return false; + } + return ArrayNotationParser.parse(expression, tagString, AddressConstraints.SINGLE_DIMENSION) + .get(0).isRange(); + } + + private static int elementsOf(Matcher matcher, String tagString) { + String expression = matcher.group("array"); + if (expression == null) { + return 1; + } + ArrayInfo dimension = ArrayNotationParser + .parse(expression, tagString, AddressConstraints.SINGLE_DIMENSION).get(0); + if (dimension.getLowerBound() - dimension.getBase() != 0) { + throw new PlcInvalidTagException("Array selection '" + expression + "' in tag '" + + tagString + "' must start at the first element: this driver addresses a " + + "named variable, so there is no offset to start from"); + } + return dimension.getSize(); } public static SimulatedTag of(String tagString) throws PlcInvalidTagException { @@ -79,13 +124,11 @@ public static SimulatedTag of(String tagString) throws PlcInvalidTagException { throw new PlcInvalidTagException("Invalid data type: " + matcher.group("dataType")); } - int numElements = 1; - if (matcher.group("numElements") != null) { - numElements = checkNumElements(dataType, Integer.parseInt(matcher.group("numElements"))); - } - return new SimulatedTag(type, name, dataType, numElements); + int numElements = checkNumElements(dataType, elementsOf(matcher, tagString)); + return new SimulatedTag(type, name, dataType, numElements, rangeWritten(matcher, tagString)); } - throw new PlcInvalidTagException("Unable to parse address: " + tagString); + throw ArrayNotationParser.invalidAddress(tagString, + "{type}/{name}[selection]:{TYPE} - for example RANDOM/foo[0..3]:INT"); } /** @@ -124,7 +167,8 @@ static boolean matches(String tagString) { @Override public String getAddressString() { - return String.format("%s/%s:%s[%d]", type.name(), name, dataType.name(), numElements); + return String.format("%s/%s%s:%s", type.name(), name, + ArrayNotationParser.render(getArrayInfo()), dataType.name()); } @Override @@ -134,8 +178,9 @@ public PlcValueType getPlcValueType() { @Override public List getArrayInfo() { - if(numElements > 1) { - return Collections.singletonList(new DefaultArrayInfo(0, numElements - 1)); + // A range is an array even when it spans one element; the count cannot express that. + if (explicitRange) { + return Collections.singletonList(new DefaultArrayInfo(0, numElements - 1, 0, true)); } return Collections.emptyList(); } @@ -158,6 +203,7 @@ public boolean equals(Object o) { } SimulatedTag simulatedTag = (SimulatedTag) o; return numElements == simulatedTag.numElements && + explicitRange == simulatedTag.explicitRange && type == simulatedTag.type && Objects.equals(name, simulatedTag.name) && Objects.equals(dataType, simulatedTag.dataType); @@ -165,7 +211,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(type, name, dataType, numElements); + return Objects.hash(type, name, dataType, numElements, explicitRange); } @Override diff --git a/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/ManualSimulatedDriverTest.java b/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/ManualSimulatedDriverTest.java index 86cfc8c918a..962f51eec4d 100644 --- a/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/ManualSimulatedDriverTest.java +++ b/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/ManualSimulatedDriverTest.java @@ -28,7 +28,7 @@ public class ManualSimulatedDriverTest { public static void main(String[] args) throws Exception { try (PlcConnection connection = PlcDriverManager.getDefault().getConnectionFactory().getConnection("simulated:hurz")){ - PlcReadRequest readRequest = connection.readRequestBuilder().addTagAddress("test", "RANDOM/dummy:UINT[100]").build(); + PlcReadRequest readRequest = connection.readRequestBuilder().addTagAddress("test", "RANDOM/dummy[0..99]:UINT").build(); PlcReadResponse readResponse = readRequest.execute().get(); System.out.println(readResponse); } diff --git a/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/connection/SimulatedDeviceTest.java b/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/connection/SimulatedDeviceTest.java index 84361a2af2e..b9050bc27e5 100644 --- a/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/connection/SimulatedDeviceTest.java +++ b/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/connection/SimulatedDeviceTest.java @@ -68,7 +68,7 @@ public void randomWString() { @Test public void randomStringArray() { SimulatedDevice device = new SimulatedDevice("foobar"); - SimulatedTag tag = SimulatedTag.of("RANDOM/foo:STRING[3]"); + SimulatedTag tag = SimulatedTag.of("RANDOM/foo[0..2]:STRING"); Optional value = device.get(tag); diff --git a/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedArrayParityTest.java b/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedArrayParityTest.java new file mode 100644 index 00000000000..e0b69caea79 --- /dev/null +++ b/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedArrayParityTest.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.simulated.tag; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.api.model.ArrayInfo; +import org.apache.plc4x.java.simulated.tag.SimulatedTag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The same selection means the same thing here as on every other driver (SC-001). + * + *

Each driver asserts this against its own address syntax; the assertions are deliberately + * identical, because that sameness is the success criterion. + */ +class SimulatedArrayParityTest { + + @Test + void aRangeOfEightStartingAtZero() { + List dimensions = SimulatedTag.of("RANDOM/foo[0..7]:INT").getArrayInfo(); + + assertEquals(1, dimensions.size(), "one dimension"); + assertEquals(8, dimensions.get(0).getSize(), "eight elements"); + assertEquals(0, dimensions.get(0).getLowerBound() - dimensions.get(0).getBase(), + "starting at offset zero"); + assertTrue(dimensions.get(0).isRange(), "written as a range"); + } + + /** + * This driver names a variable and knows no element size, so it can only select from the + * first element. A selection that starts elsewhere is refused rather than silently read from + * the start - see FR-034. + */ + @Test + void aSelectionMustStartAtTheFirstElement() { + assertThrows(PlcInvalidTagException.class, () -> SimulatedTag.of("RANDOM/foo[4]:INT")); + assertThrows(PlcInvalidTagException.class, () -> SimulatedTag.of("RANDOM/foo[4..7]:INT")); + + assertTrue(SimulatedTag.of("RANDOM/foo[0]:INT").getArrayInfo().isEmpty(), "[0] is still a scalar"); + } + + /** + * The case the notation exists to distinguish: a range spanning one element is an array of + * one, while a bare index is a scalar. No element count can tell them apart, so a driver that + * derives its shape from the count alone silently collapses them - which is how the SLMP tag + * reported a one-element range as a scalar while plc4go's reported a list. + */ + @Test + void aOneElementRangeIsAnArrayOfOne() { + List dimensions = SimulatedTag.of("RANDOM/foo[0..0]:INT").getArrayInfo(); + + assertEquals(1, dimensions.size(), "one dimension"); + assertEquals(1, dimensions.get(0).getSize(), "one element"); + assertTrue(dimensions.get(0).isRange(), "written as a range"); + + assertTrue(SimulatedTag.of("RANDOM/foo[0]:INT").getArrayInfo().isEmpty(), + "while the bare index of the same element stays a scalar"); + } + + @Test + void anOmittedSelectionIsAScalar() { + assertTrue(SimulatedTag.of("RANDOM/foo:INT").getArrayInfo().isEmpty()); + } +} diff --git a/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedLegacyAddressTest.java b/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedLegacyAddressTest.java new file mode 100644 index 00000000000..f69429b705a --- /dev/null +++ b/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedLegacyAddressTest.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.simulated.tag; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.simulated.tag.SimulatedTag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Addresses written before the array notation was unified must not parse. + * + *

The brackets moved from after the type to before it, which is what turns an otherwise + * silent change of meaning into a failure: {@code [4]} used to mean four elements and now means + * the fifth, so an unmodified address that still parsed would quietly return different data. + */ +class SimulatedLegacyAddressTest { + + @Test + void legacyForm0IsRejected() { + assertThrows(PlcInvalidTagException.class, () -> SimulatedTag.of("RANDOM/foo:INT[4]")); + } + + @Test + void theReplacementFormParses() { + assertNotNull(SimulatedTag.of("RANDOM/foo[0..3]:INT")); + } + + /** The message has to hand an upgrading user the address to write, not just a regex. */ + @Test + void theErrorNamesTheReplacementAddress() { + PlcInvalidTagException thrown = + assertThrows(PlcInvalidTagException.class, () -> SimulatedTag.of("RANDOM/foo:INT[4]")); + assertTrue(thrown.getMessage().contains("RANDOM/foo[0..3]:INT"), thrown::getMessage); + } +} diff --git a/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedTagBoundTest.java b/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedTagBoundTest.java index 9c8d1d4771c..d3ad98472d8 100644 --- a/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedTagBoundTest.java +++ b/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedTagBoundTest.java @@ -41,30 +41,30 @@ private static int elementsOf(String address) { @Test void aCountWhoseSizeWouldNotFitAnIntIsRefused() { // 400000000 LREALs is 3.2e9 bytes, which as an int is negative. - assertThrows(PlcInvalidTagException.class, () -> SimulatedTag.of("RANDOM/foo:LREAL[400000000]")); + assertThrows(PlcInvalidTagException.class, () -> SimulatedTag.of("RANDOM/foo[0..399999999]:LREAL")); } @Test void aCountThatWouldBeMerelyEnormousIsAlsoRefused() { - assertThrows(PlcInvalidTagException.class, () -> SimulatedTag.of("RANDOM/foo:LREAL[300000000]")); + assertThrows(PlcInvalidTagException.class, () -> SimulatedTag.of("RANDOM/foo[0..299999999]:LREAL")); } @Test void aCountTooWideToBeANumberIsAnInvalidTagNotANumberFormatError() { - assertThrows(PlcInvalidTagException.class, () -> SimulatedTag.of("RANDOM/foo:LREAL[99999999999]")); + assertThrows(PlcInvalidTagException.class, () -> SimulatedTag.of("RANDOM/foo[0..99999999998]:LREAL")); } @Test void aWiderElementLeavesRoomForFewerOfThem() { // The budget is in bytes, so the same count passes as a byte and fails as an eight-byte // double: 4194304 of them is 32MiB, past the budget, while as SINT it is 4MiB. - assertEquals(4194304, elementsOf("RANDOM/foo:SINT[4194304]")); - assertThrows(PlcInvalidTagException.class, () -> SimulatedTag.of("RANDOM/foo:LREAL[4194304]")); + assertEquals(4194304, elementsOf("RANDOM/foo[0..4194303]:SINT")); + assertThrows(PlcInvalidTagException.class, () -> SimulatedTag.of("RANDOM/foo[0..4194303]:LREAL")); } @Test void aPlausibleCountStillParses() { - assertEquals(16, elementsOf("RANDOM/foo:LREAL[16]")); + assertEquals(16, elementsOf("RANDOM/foo[0..15]:LREAL")); } @Test diff --git a/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedTagTest.java b/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedTagTest.java index 6bedd43518a..b7a4923176b 100644 --- a/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedTagTest.java +++ b/plc4j/drivers/simulated/src/test/java/org/apache/plc4x/java/simulated/tag/SimulatedTagTest.java @@ -31,8 +31,8 @@ public class SimulatedTagTest { @Test public void constructor() { - assertThat(SimulatedTag.matches("RANDOM/test:DINT[2]"), equalTo(true)); - SimulatedTag tag = SimulatedTag.of("RANDOM/test:DINT[2]"); + assertThat(SimulatedTag.matches("RANDOM/test[0..1]:DINT"), equalTo(true)); + SimulatedTag tag = SimulatedTag.of("RANDOM/test[0..1]:DINT"); assertThat(tag.getType(), equalTo(SimulatedTagType.RANDOM)); assertThat(tag.getName(), equalTo("test")); assertThat(tag.getPlcValueType().name(), equalTo("DINT")); @@ -44,8 +44,8 @@ public void constructor() { /*[TODO] Add support for Full Java Type Names back in after plc4go changes have merged @Test public void constructor() { - assertThat(SimulatedTag.matches("RANDOM/test:Int[2]"), equalTo(true)); - SimulatedTag tag = SimulatedTag.of("RANDOM/test:Int[2]"); + assertThat(SimulatedTag.matches("RANDOM/test[0..1]:Int"), equalTo(true)); + SimulatedTag tag = SimulatedTag.of("RANDOM/test[0..1]:Int"); assertThat(tag.getType(), equalTo(SimulatedTagType.RANDOM)); assertThat(tag.getName(), equalTo("test")); assertThat(tag.getPlcDataType(), equalTo("Integer")); diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java index 65df346fa34..2ecca8b6d3a 100644 --- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java +++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java @@ -303,7 +303,7 @@ protected CompletableFuture onWrite(PlcWriteRequest writeReque } private CompletableFuture writeSingleTag(SlmpTag tag, PlcValue value) { - byte[] payload = tag.getDataType().encode(value, tag.getQuantity()); + byte[] payload = tag.getDataType().encode(value, tag.getQuantity(), tag.isExplicitRange()); if (payload == null) { return CompletableFuture.completedFuture(PlcResponseCode.INVALID_DATA); } diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java index 386b4b313cc..78bc1f16d7f 100644 --- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java +++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java @@ -70,6 +70,16 @@ public int getWordsPerElement() { * Returns {@code null} when {@code responseData} is shorter than required (caller maps to INVALID_DATA). */ public PlcValue decode(byte[] responseData, int quantity) { + return decode(responseData, quantity, quantity > 1); + } + + /** + * Decode {@code quantity} elements, rendering them as a list when {@code asList} says the + * address selected a range. A one-element range is still a range - {@code D100[4..4]} is a + * list of one - which the count alone cannot express, so the caller passes what the address + * said. + */ + public PlcValue decode(byte[] responseData, int quantity, boolean asList) { int requiredBytes = quantity * wordsPerElement * 2; if (responseData == null || responseData.length < requiredBytes) { return null; @@ -80,7 +90,7 @@ public PlcValue decode(byte[] responseData, int quantity) { WithOption.WithSignedIntegerEncoding("twos-complement"), WithOption.WithFloatEncoding("IEEE754")); try { - if (quantity == 1) { + if (!asList) { return readOne(buffer); } List values = new ArrayList<>(quantity); @@ -100,6 +110,14 @@ public PlcValue decode(byte[] responseData, int quantity) { * (caller maps to INVALID_DATA), symmetric with {@link #decode}. */ public byte[] encode(PlcValue value, int quantity) { + return encode(value, quantity, quantity > 1); + } + + /** + * Encode {@code quantity} elements, expecting a list when {@code asList} says the address + * selected a range - symmetric with {@link #decode(byte[], int, boolean)}. + */ + public byte[] encode(PlcValue value, int quantity, boolean asList) { int totalBytes = quantity * wordsPerElement * 2; WriteBufferByteBased buffer = new WriteBufferByteBased(new byte[totalBytes], WithByteBasedOption.WithByteOrder("LITTLE_ENDIAN"), @@ -107,7 +125,7 @@ public byte[] encode(PlcValue value, int quantity) { WithOption.WithSignedIntegerEncoding("twos-complement"), WithOption.WithFloatEncoding("IEEE754")); try { - if (quantity == 1) { + if (!asList) { if (value == null || value.isList()) { return null; } diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java index 67d125db1e7..7c1ab5f4b43 100644 --- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java +++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java @@ -39,7 +39,7 @@ static PlcResponseItem mapTag(SlmpTag tag, int endCode, byte[] respons LOGGER.warn("SLMP device returned endCode {} for {}", String.format("0x%04X", endCode), tag); return new DefaultPlcResponseItem<>(PlcResponseCode.REMOTE_ERROR, null); } - PlcValue value = tag.getDataType().decode(responseData, tag.getQuantity()); + PlcValue value = tag.getDataType().decode(responseData, tag.getQuantity(), tag.isExplicitRange()); if (value == null) { LOGGER.warn("SLMP response too short for {} ({} bytes)", tag, responseData == null ? 0 : responseData.length); diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java index 9fe90059f46..2ba702412ee 100644 --- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java +++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java @@ -24,6 +24,8 @@ import org.apache.plc4x.java.api.types.PlcValueType; import org.apache.plc4x.java.slmp.SlmpDataType; import org.apache.plc4x.java.slmp.readwrite.SlmpDeviceCode; +import org.apache.plc4x.java.spi.drivers.model.AddressConstraints; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo; import java.io.Serializable; @@ -41,8 +43,9 @@ public class SlmpTag implements PlcTag, Serializable { public static final Pattern ADDRESS_PATTERN = Pattern.compile( - "^(?[A-Za-z]+)(?0[xX])?(?

[0-9A-Fa-f]+)" + - "(:(?[A-Za-z_]+))?(\\[(?\\d+)])?$"); + "^(?[A-Za-z]+)(?0[xX])?(?
[0-9A-Fa-f]+)" + + ArrayNotationParser.ARRAY_GROUP + + "(:(?[A-Za-z_]+))?$"); /** Conservative single-frame word ceiling for 3E binary Batch Read/Write (not the exact device max). */ static final int MAX_POINTS = 960; @@ -55,38 +58,50 @@ public class SlmpTag implements PlcTag, Serializable { private final SlmpDataType dataType; private final int quantity; + /** + * Whether the address wrote the selection as a range. A one-element range is still a range - + * {@code D100[4]} yields a scalar and {@code D100[4..4]} a list of one - and the count alone + * cannot say which was written, so the parser's answer is carried here. + */ + private final boolean explicitRange; + public SlmpTag(SlmpDeviceCode deviceCode, int deviceNumber, SlmpDataType dataType, int quantity) { + this(deviceCode, deviceNumber, dataType, quantity, quantity > 1); + } + + public SlmpTag(SlmpDeviceCode deviceCode, int deviceNumber, SlmpDataType dataType, int quantity, + boolean explicitRange) { this.deviceCode = deviceCode; this.deviceNumber = deviceNumber; this.dataType = dataType; this.quantity = quantity; + this.explicitRange = explicitRange; } public static SlmpTag of(String addressString) { Matcher matcher = ADDRESS_PATTERN.matcher(addressString); if (!matcher.matches()) { - throw new PlcInvalidTagException("Unable to parse SLMP address: " + addressString); + throw ArrayNotationParser.invalidAddress(addressString, + "{device}{address}[selection]:{TYPE} - for example D100[0..3]:INT"); } String deviceToken = matcher.group("device").toUpperCase(); SlmpDeviceCode device; - int radix; - switch (deviceToken) { - case "D": + int radix = switch (deviceToken) { + case "D" -> { device = SlmpDeviceCode.D; - radix = 10; - break; - case "R": + yield 10; + } + case "R" -> { device = SlmpDeviceCode.R; - radix = 10; - break; - case "W": + yield 10; + } + case "W" -> { device = SlmpDeviceCode.W; - radix = 16; - break; - default: - throw new PlcInvalidTagException( - "device '" + deviceToken + "' not supported in this version (word devices D/W/R only)"); - } + yield 16; + } + default -> throw new PlcInvalidTagException( + "device '" + deviceToken + "' not supported in this version (word devices D/W/R only)"); + }; boolean hasHexPrefix = matcher.group("hexPrefix") != null; if (hasHexPrefix && radix != 16) { @@ -117,16 +132,32 @@ public static SlmpTag of(String addressString) { } } - String quantityToken = matcher.group("quantity"); - int quantity; - if (quantityToken == null) { - quantity = 1; - } else { - try { - quantity = Integer.parseInt(quantityToken); - } catch (NumberFormatException e) { - throw new PlcInvalidTagException("quantity out of range in: " + addressString); + // The selection sits between the address and the type; its offset moves the device + // number, and its size is how many devices are read. + String arrayToken = matcher.group("array"); + int quantity = 1; + boolean explicitRange = false; + if (arrayToken != null) { + ArrayInfo dimension = ArrayNotationParser + .parse(arrayToken, addressString, AddressConstraints.SINGLE_DIMENSION).getFirst(); + // The offset counts elements; a device number counts 16-bit words. They coincide only + // for a one-word type, which is why D100[4]:INT looked right while D100[4]:DINT read + // four words short of its target. This is the same scale getWordsPerElement() applies + // to the point count below, so the offset and the length cannot disagree. + // + // Done in long and re-checked: the scaled offset can carry a base that was itself + // legal past the 24-bit device-address limit, and the int result would then be + // truncated on serialization into some other, apparently valid, device. + long scaled = (long) deviceNumber + + (long) (dimension.getLowerBound() - dimension.getBase()) * dataType.getWordsPerElement(); + if (scaled > MAX_DEVICE_NUMBER) { + throw new PlcInvalidTagException("selection resolves to device number " + scaled + + ", which exceeds the 24-bit SLMP device-address range [0.." + MAX_DEVICE_NUMBER + + "]: " + addressString); } + deviceNumber = (int) scaled; + quantity = dimension.getSize(); + explicitRange = dimension.isRange(); } if (quantity < 1) { throw new PlcInvalidTagException("quantity must be >= 1 in: " + addressString); @@ -137,7 +168,7 @@ public static SlmpTag of(String addressString) { throw new PlcInvalidTagException("requested " + numberOfPoints + " words exceeds the v0 single-frame " + "Batch Read/Write ceiling of " + MAX_POINTS + " (no optimizer to split): " + addressString); } - return new SlmpTag(device, deviceNumber, dataType, quantity); + return new SlmpTag(device, deviceNumber, dataType, quantity, explicitRange); } public SlmpDeviceCode getDeviceCode() { @@ -167,12 +198,10 @@ public String getAddressString() { ? "0x" + Integer.toHexString(deviceNumber).toUpperCase() : Integer.toString(deviceNumber); StringBuilder sb = new StringBuilder(deviceCode.name()).append(addr); + sb.append(ArrayNotationParser.render(getArrayInfo())); if (dataType != SlmpDataType.WORD || quantity != 1) { sb.append(':').append(dataType.name()); } - if (quantity != 1) { - sb.append('[').append(quantity).append(']'); - } return sb.toString(); } @@ -183,12 +212,21 @@ public PlcValueType getPlcValueType() { @Override public List getArrayInfo() { - if (quantity > 1) { - return Collections.singletonList(new DefaultArrayInfo(0, quantity - 1)); + // A range is an array even when it spans one element, so the flag decides the shape and + // the count only sizes it. Deriving the shape from the count alone reported D100[4..4] as + // a scalar, contradicting the notation's own rule and plc4go's SLMP tag, which carries + // the same flag. + if (explicitRange) { + return Collections.singletonList(new DefaultArrayInfo(0, quantity - 1, 0, true)); } return Collections.emptyList(); } + /** Whether the address wrote a range, as opposed to selecting a single element. */ + public boolean isExplicitRange() { + return explicitRange; + } + @Override public String toString() { return "SlmpTag{" + getAddressString() + '}'; diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java index 04a36a49d6e..e2c3e119589 100644 --- a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java @@ -37,7 +37,7 @@ private static byte[] hex(String s) { @Test void normalCompletionDecodesValue() { - SlmpTag tag = SlmpTag.of("D350:WORD[2]"); + SlmpTag tag = SlmpTag.of("D350[0..1]:WORD"); var item = SlmpResponseMapper.mapTag(tag, 0x0000, hex("ab560f17")); assertEquals(PlcResponseCode.OK, item.getResponseCode()); assertEquals(0x56AB, item.getValue().getList().get(0).getInt()); @@ -53,7 +53,7 @@ void nonZeroEndCodeMapsToRemoteError() { @Test void shortResponseMapsToInvalidData() { - SlmpTag tag = SlmpTag.of("D350:WORD[2]"); // wants 2 words + SlmpTag tag = SlmpTag.of("D350[0..1]:WORD"); // wants 2 words var item = SlmpResponseMapper.mapTag(tag, 0x0000, hex("ab56")); // only 1 assertEquals(PlcResponseCode.INVALID_DATA, item.getResponseCode()); } diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java index a38c367d871..3eaa8d461bd 100644 --- a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java @@ -40,7 +40,7 @@ private static String toHex(byte[] b) { @Test void buildsBatchReadFrameMatchingSh080008Example() throws Exception { // SH-080008 section 8.2 (Batch Read and Write): read D350, 2 words. - SlmpTag tag = SlmpTag.of("D350:WORD[2]"); + SlmpTag tag = SlmpTag.of("D350[0..1]:WORD"); SlmpReadRequest data = new SlmpReadRequest( tag.getDeviceNumber(), tag.getDeviceCode(), tag.getNumberOfPoints()); SlmpRequestFrame3E frame = new SlmpRequestFrame3E(0x0000, 0x0401, 0x0000, data); diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpArrayParityTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpArrayParityTest.java new file mode 100644 index 00000000000..99feabd3364 --- /dev/null +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpArrayParityTest.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.slmp.tag; + +import org.apache.plc4x.java.api.model.ArrayInfo; +import org.apache.plc4x.java.slmp.tag.SlmpTag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The same selection means the same thing here as on every other driver (SC-001). + * + *

Each driver asserts this against its own address syntax; the assertions are deliberately + * identical, because that sameness is the success criterion. + */ +class SlmpArrayParityTest { + + @Test + void aRangeOfEightStartingAtZero() { + List dimensions = SlmpTag.of("D100[0..7]:INT").getArrayInfo(); + + assertEquals(1, dimensions.size(), "one dimension"); + assertEquals(8, dimensions.get(0).getSize(), "eight elements"); + assertEquals(0, dimensions.get(0).getLowerBound() - dimensions.get(0).getBase(), + "starting at offset zero"); + assertTrue(dimensions.get(0).isRange(), "written as a range"); + } + + @Test + void aBareIndexIsAScalar() { + assertTrue(SlmpTag.of("D100[4]:INT").getArrayInfo().isEmpty()); + } + + /** + * The case the notation exists to distinguish: a range spanning one element is an array of + * one, while a bare index is a scalar. No element count can tell them apart, so a driver that + * derives its shape from the count alone silently collapses them - which is how the SLMP tag + * reported a one-element range as a scalar while plc4go's reported a list. + */ + @Test + void aOneElementRangeIsAnArrayOfOne() { + List dimensions = SlmpTag.of("D100[4..4]:INT").getArrayInfo(); + + assertEquals(1, dimensions.size(), "one dimension"); + assertEquals(1, dimensions.get(0).getSize(), "one element"); + assertTrue(dimensions.get(0).isRange(), "written as a range"); + + assertTrue(SlmpTag.of("D100[4]:INT").getArrayInfo().isEmpty(), + "while the bare index of the same element stays a scalar"); + } + + @Test + void anOmittedSelectionIsAScalar() { + assertTrue(SlmpTag.of("D100:INT").getArrayInfo().isEmpty()); + } +} diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpLegacyAddressTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpLegacyAddressTest.java new file mode 100644 index 00000000000..7c2338751b7 --- /dev/null +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpLegacyAddressTest.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.slmp.tag; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.slmp.tag.SlmpTag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Addresses written before the array notation was unified must not parse. + * + *

The brackets moved from after the type to before it, which is what turns an otherwise + * silent change of meaning into a failure: {@code [4]} used to mean four elements and now means + * the fifth, so an unmodified address that still parsed would quietly return different data. + */ +class SlmpLegacyAddressTest { + + @Test + void legacyForm0IsRejected() { + assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D100:WORD[2]")); + } + + @Test + void legacyForm1IsRejected() { + assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("W1A:WORD[10]")); + } + + @Test + void theReplacementFormParses() { + assertNotNull(SlmpTag.of("D100[0..1]:WORD")); + } + + /** The message has to hand an upgrading user the address to write, not just a regex. */ + @Test + void theErrorNamesTheReplacementAddress() { + PlcInvalidTagException thrown = + assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D100:WORD[2]")); + assertTrue(thrown.getMessage().contains("D100[0..1]:WORD"), thrown::getMessage); + } +} diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpSelectionOffsetTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpSelectionOffsetTest.java new file mode 100644 index 00000000000..7f207361724 --- /dev/null +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpSelectionOffsetTest.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.slmp.tag; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * A selection offset counts elements; an SLMP device number counts 16-bit words. The read length + * already scales by {@code getWordsPerElement()}, so an unscaled offset does not shorten the read - + * it moves it to the wrong devices, silently. + */ +class SlmpSelectionOffsetTest { + + @Test + @DisplayName("a one-word type advances one device per element") + void oneWordPerElement() { + assertEquals(104, SlmpTag.of("D100[4]:INT").getDeviceNumber()); + } + + @Test + @DisplayName("a two-word type advances two devices per element") + void twoWordsPerElement() { + // The fifth DINT begins eight words along, at D108 - not D104. + assertEquals(108, SlmpTag.of("D100[4]:DINT").getDeviceNumber()); + } + + @Test + @DisplayName("a REAL advances by its two words as well") + void realsAdvanceByTwoWords() { + assertEquals(104, SlmpTag.of("D100[2]:REAL").getDeviceNumber()); + } + + @Test + @DisplayName("a selection that scales past the device-address limit is rejected") + void aScaledOffsetPastTheLimitIsRejected() { + // The base is legal on its own; the selection carries it past the 24-bit field, where the + // int result would have been truncated on serialization into some other, valid-looking + // device. + assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D16777215[1]:INT")); + assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D16777214[1]:DINT")); + } + + @Test + @DisplayName("a selection that stays inside the device-address limit still parses") + void aScaledOffsetInsideTheLimitStillParses() { + assertEquals(16777215, SlmpTag.of("D16777214[1]:INT").getDeviceNumber()); + } + + @Test + @DisplayName("a declared base is measured in elements too") + void aDeclaredBaseIsInElements() { + // [4..7;4] starts at the declared base, so it shifts nothing regardless of the width. + assertEquals(100, SlmpTag.of("D100[4..7;4]:DINT").getDeviceNumber()); + } +} diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpTagTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpTagTest.java index fcfbf030052..3b851f98502 100644 --- a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpTagTest.java +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpTagTest.java @@ -39,7 +39,7 @@ void parsesDecimalDeviceWithDefaults() { @Test void parsesDataTypeAndQuantity() { - SlmpTag tag = SlmpTag.of("R200:REAL[4]"); + SlmpTag tag = SlmpTag.of("R200[0..3]:REAL"); assertEquals(SlmpDeviceCode.R, tag.getDeviceCode()); assertEquals(200, tag.getDeviceNumber()); assertEquals(SlmpDataType.REAL, tag.getDataType()); @@ -49,7 +49,7 @@ void parsesDataTypeAndQuantity() { @Test void parsesHexLinkRegisterBareForm() { - SlmpTag tag = SlmpTag.of("W1A:WORD[10]"); + SlmpTag tag = SlmpTag.of("W1A[0..9]:WORD"); assertEquals(SlmpDeviceCode.W, tag.getDeviceCode()); assertEquals(0x1A, tag.getDeviceNumber()); // W is hex assertEquals(10, tag.getQuantity()); @@ -64,7 +64,7 @@ void parsesHexLinkRegisterExplicit0xForm() { @Test void parses0xPrefixCombinedWithDatatypeAndQuantity() { // the documented example form: 0x prefix + datatype + quantity in one address - SlmpTag tag = SlmpTag.of("W0x1A:WORD[10]"); + SlmpTag tag = SlmpTag.of("W0x1A[0..9]:WORD"); assertEquals(SlmpDeviceCode.W, tag.getDeviceCode()); assertEquals(0x1A, tag.getDeviceNumber()); assertEquals(SlmpDataType.WORD, tag.getDataType()); @@ -73,7 +73,7 @@ void parses0xPrefixCombinedWithDatatypeAndQuantity() { @Test void canonicalAddressStringRoundTrips() { - assertEquals("D350:INT[2]", SlmpTag.of("D350:INT[2]").getAddressString()); + assertEquals("D350[0..1]:INT", SlmpTag.of("D350[0..1]:INT").getAddressString()); assertEquals("W0x1A", SlmpTag.of("W1A").getAddressString()); // W prints 0x hex } @@ -94,20 +94,25 @@ void rejectsHexDigitsInDecimalDevice() { } @Test - void rejectsZeroQuantity() { - assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D100:WORD[0]")); + /** + * A quantity of zero used to be rejected. The notation has no way to say it: [0] names the + * element at offset 0, which is one device, and an empty bracket names nothing at all. + */ + void rejectsAnEmptySelection() { + assertEquals(1, SlmpTag.of("D100[0]:WORD").getQuantity()); + assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D100[]:WORD")); } @Test void rejectsOverCeiling() { - assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D0:WORD[961]")); + assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D0[0..960]:WORD")); } @Test void rejectsQuantityOverflowingInt() { // a quantity beyond Integer.MAX_VALUE must surface as PlcInvalidTagException, // consistent with the device-number parse, not a raw NumberFormatException - assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D0:WORD[999999999999]")); + assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D0[0..999999999998]:WORD")); } @Test @@ -121,6 +126,6 @@ void rejectsDeviceNumberExceeding24Bit() { void rejectsQuantityWhoseWordCountOverflowsInt() { // quantity * wordsPerElement must not overflow int and slip past the MAX_POINTS ceiling: // 2147483647 * 2 words wraps negative in int arithmetic, so the tag would otherwise be accepted - assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D0:REAL[2147483647]")); + assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D0[0..2147483646]:REAL")); } } diff --git a/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml b/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml index ae09036420f..4fdfd3056ac 100644 --- a/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml +++ b/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml @@ -41,7 +41,7 @@ value -

D350:WORD[2]
+
D350[0..1]:WORD
diff --git a/plc4j/drivers/umas/src/main/java/org/apache/plc4x/java/umas/UmasConnection.java b/plc4j/drivers/umas/src/main/java/org/apache/plc4x/java/umas/UmasConnection.java index d7d7c1b74a3..dd9ff4b6dd1 100644 --- a/plc4j/drivers/umas/src/main/java/org/apache/plc4x/java/umas/UmasConnection.java +++ b/plc4j/drivers/umas/src/main/java/org/apache/plc4x/java/umas/UmasConnection.java @@ -51,6 +51,7 @@ import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcWriteResponse; import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcResponseItem; import org.apache.plc4x.java.spi.drivers.messages.items.PlcResponseItem; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo; import org.apache.plc4x.java.spi.drivers.tags.PlcTagHandler; import org.apache.plc4x.java.spi.transports.api.TransportInstance; @@ -519,7 +520,17 @@ private PlcResponseItem readSingleTag(String tagName, PlcTag tag) { return new DefaultPlcResponseItem<>(PlcResponseCode.INVALID_ADDRESS, null); } - String symbolicAddress = symbolicTag.getSymbolicAddress().toLowerCase(); + if (!symbolicTag.getSelection().isEmpty()) { + // The read reference is built from the symbol's own block and offset; selecting an + // element would need the per-element arithmetic this driver does not do yet. Report + // it rather than return the whole variable as though it were the element asked for. + LOGGER.warn("Read tag '{}': selecting array elements is not supported yet by the UMAS" + + " driver; address the whole variable instead", tagName); + return new DefaultPlcResponseItem<>(PlcResponseCode.UNSUPPORTED, null); + } + // Look up the symbolic path; a selection is never part of the symbol's name. + String symbolicAddress = + ArrayNotationParser.addressPart(symbolicTag.getSymbolicAddress()).toLowerCase(); Optional symbolOpt = Optional.ofNullable(symbolTable.get(symbolicAddress)); if (symbolOpt.isEmpty()) { LOGGER.warn("Read tag '{}': symbol '{}' not found in symbol table", tagName, symbolicAddress); @@ -660,7 +671,13 @@ private PlcResponseCode writeSingleTag(String tagName, PlcTag tag, PlcValue valu if (!(tag instanceof SymbolicUmasTag symbolicTag)) { return PlcResponseCode.INVALID_ADDRESS; } - String symbolicAddress = symbolicTag.getSymbolicAddress().toLowerCase(); + if (!symbolicTag.getSelection().isEmpty()) { + LOGGER.warn("Write tag '{}': selecting array elements is not supported yet by the UMAS" + + " driver; address the whole variable instead", tagName); + return PlcResponseCode.UNSUPPORTED; + } + String symbolicAddress = + ArrayNotationParser.addressPart(symbolicTag.getSymbolicAddress()).toLowerCase(); Optional symbolOpt = Optional.ofNullable(symbolTable.get(symbolicAddress)); if (symbolOpt.isEmpty()) { return PlcResponseCode.NOT_FOUND; diff --git a/plc4j/drivers/umas/src/main/java/org/apache/plc4x/java/umas/tag/SymbolicUmasTag.java b/plc4j/drivers/umas/src/main/java/org/apache/plc4x/java/umas/tag/SymbolicUmasTag.java index e743c6ad5fa..9885081c6c7 100644 --- a/plc4j/drivers/umas/src/main/java/org/apache/plc4x/java/umas/tag/SymbolicUmasTag.java +++ b/plc4j/drivers/umas/src/main/java/org/apache/plc4x/java/umas/tag/SymbolicUmasTag.java @@ -20,6 +20,8 @@ import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; import org.apache.plc4x.java.api.model.ArrayInfo; +import org.apache.plc4x.java.spi.drivers.model.AddressConstraints; +import org.apache.plc4x.java.spi.drivers.model.ArrayNotationParser; import org.apache.plc4x.java.api.types.PlcValueType; import java.util.Collections; @@ -42,6 +44,18 @@ public class SymbolicUmasTag implements UmasTag { private static final Pattern SYMBOLIC_ADDRESS_PATTERN = Pattern.compile("^([a-zA-Z_]\\w*)(\\[\\d+])*(\\.([a-zA-Z_]\\w*)(\\[\\d+])*)*$"); + /** + * A range has to be contiguous to be one read. Only the last dimension of the trailing + * selection may span more than one element: "a[1].b[2..5]" is one run of a single + * sub-structure, while "a[1..3].b" would be member b of three separate elements and + * "a[1..3][2]" a strided slice - neither of which any single request can fetch. + * + *

An interior range is already refused by the symbolic path pattern, which accepts only + * bare indices between the dots. + */ + private static final AddressConstraints CONSTRAINTS = + AddressConstraints.UNCONSTRAINED.withOnlyTrailingDimensionMayBeRange(true); + private final String symbolicAddress; private final PlcValueType dataType; private final List arrayInfo; @@ -56,11 +70,48 @@ public static SymbolicUmasTag of(String address) { if (!matches(address)) { throw new PlcInvalidTagException(address, SYMBOLIC_ADDRESS_PATTERN, "{symbolic-address}"); } - return new SymbolicUmasTag(address, null, Collections.emptyList()); + // A trailing bracket run is the selection; everything before it is the symbolic path. + // Splitting first is what lets the last segment carry a range - the per-segment group in + // SYMBOLIC_ADDRESS_PATTERN would otherwise swallow a bare trailing index. + String expression = ArrayNotationParser.expressionPart(address); + List selection = expression.isEmpty() + ? Collections.emptyList() + : ArrayNotationParser.parse(expression, address, CONSTRAINTS); + return new SymbolicUmasTag(address, null, selection); } public static boolean matches(String address) { - return SYMBOLIC_ADDRESS_PATTERN.matcher(address).matches(); + return SYMBOLIC_ADDRESS_PATTERN.matcher(ArrayNotationParser.addressPart(address)).matches(); + } + + /** + * The selection the address states, or an empty list where it states none. Derived from the + * address rather than from the constructor, because a tag built directly - as the driver does + * when it browses the symbol table - carries the variable's declared shape in its arrayInfo, + * not the user's selection. + * + *

Distinct from {@link #getArrayInfo()}, which describes the shape of the value the caller + * receives. + */ + public List getSelection() { + String expression = ArrayNotationParser.expressionPart(symbolicAddress); + return expression.isEmpty() + ? Collections.emptyList() + : ArrayNotationParser.parse(expression, symbolicAddress, CONSTRAINTS); + } + + /** + * The declared lower bound the address states for its trailing dimension, or {@code null} + * where it states none. The device's own declaration is authoritative; this is the user's + * statement of intent, to be checked against it when the symbol is resolved. + */ + public Integer getDeclaredBase() { + String expression = ArrayNotationParser.expressionPart(symbolicAddress); + if (expression.isEmpty() || !expression.contains(";")) { + return null; + } + List dimensions = ArrayNotationParser.parse(expression, symbolicAddress); + return dimensions.get(dimensions.size() - 1).getBase(); } public String getSymbolicAddress() { @@ -81,7 +132,17 @@ public PlcValueType getPlcValueType() { } @Override + /** + * The shape of the value the caller receives: empty for a scalar, one entry per dimension + * for an array. A bare index selects one element and so reports empty; a range reports its + * dimensions. Where the address states no selection, the driver fills this in from the + * device's declared dimensions so a bare array address reports the whole array. + */ public List getArrayInfo() { + if (ArrayNotationParser.selectsSingleElement( + ArrayNotationParser.expressionPart(symbolicAddress))) { + return Collections.emptyList(); + } return arrayInfo; } diff --git a/plc4j/drivers/umas/src/test/java/org/apache/plc4x/java/umas/tag/SymbolicUmasTagSelectionTest.java b/plc4j/drivers/umas/src/test/java/org/apache/plc4x/java/umas/tag/SymbolicUmasTagSelectionTest.java new file mode 100644 index 00000000000..bf6ec8fd94e --- /dev/null +++ b/plc4j/drivers/umas/src/test/java/org/apache/plc4x/java/umas/tag/SymbolicUmasTagSelectionTest.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.umas.tag; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * What a UMAS symbolic address states about array selection. + * + *

The driver cannot execute a selection yet - its read reference is built from a symbol's own + * block and offset, with no per-element arithmetic - so the connection refuses one rather than + * returning the whole variable (FR-033). The address itself still parses, which is what these + * assertions cover. + */ +class SymbolicUmasTagSelectionTest { + + @Test + void aBareAddressStatesNoSelection() { + assertTrue(SymbolicUmasTag.of("MyVar").getSelection().isEmpty()); + assertTrue(SymbolicUmasTag.of("MyVar").getArrayInfo().isEmpty()); + } + + /** A bare index is one element, so it reports no array info to the caller (FR-023). */ + @Test + void aBareIndexIsAScalarToTheCaller() { + assertEquals(1, SymbolicUmasTag.of("MyVar[1]").getSelection().size()); + assertTrue(SymbolicUmasTag.of("MyVar[1]").getArrayInfo().isEmpty()); + } + + @Test + void aRangeStatesItsDimensions() { + assertEquals(8, SymbolicUmasTag.of("MyVar[1..8]").getSelection().get(0).getSize()); + assertFalse(SymbolicUmasTag.of("MyVar[1..8]").getArrayInfo().isEmpty()); + } + + /** Only the last dimension may span, as everywhere else (FR-030). */ + @Test + void aRangeBeforeTheLastDimensionIsRefused() { + assertThrows(PlcInvalidTagException.class, () -> SymbolicUmasTag.of("MyVar[1..3].member")); + assertThrows(PlcInvalidTagException.class, () -> SymbolicUmasTag.of("MyVar[1..3][2]")); + assertNotNull(SymbolicUmasTag.of("MyVar[1][2..5]")); + } + + /** Interior indices stay part of the symbolic path. */ + @Test + void interiorIndicesAreAcceptedAsPath() { + assertNotNull(SymbolicUmasTag.of("MyVar[1].member[2]")); + } + + @Test + void theDeclaredBaseIsCarriedForVerification() { + assertEquals(1, SymbolicUmasTag.of("MyVar[1..8;1]").getDeclaredBase()); + assertNull(SymbolicUmasTag.of("MyVar[1..8]").getDeclaredBase()); + } +} diff --git a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/AddressConstraints.java b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/AddressConstraints.java new file mode 100644 index 00000000000..4299a2e295c --- /dev/null +++ b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/AddressConstraints.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.spi.drivers.model; + +/** + * What a protocol can actually encode of an array selection. The notation is the same for every + * driver, but the wire formats are not: an EtherNet/IP array index travels in a CIP MemberID + * whose instance field is a uint 8, and a driver addressing linear memory has no second dimension + * to express. A driver states its limits here, and a selection that exceeds them is reported when + * the address is parsed rather than truncated when it is serialized. + * + * @param maxIndex the largest start offset the protocol can encode. It bounds where a + * selection begins, not where it ends: a protocol carrying a start index and an + * element count can read past this bound, it just cannot start past it + * @param maxDimensions how many dimensions the wire format carries + * @param onlyTrailingDimensionMayBeRange whether every dimension but the last must be a single + * element - true where one request carries a single element count for the whole address + */ +public record AddressConstraints(int maxIndex, int maxDimensions, boolean onlyTrailingDimensionMayBeRange) { + + /** No limits beyond the grammar itself. */ + public static final AddressConstraints UNCONSTRAINED = + new AddressConstraints(Integer.MAX_VALUE, Integer.MAX_VALUE, false); + + /** A protocol addressing linear memory: any index, but only one dimension. */ + public static final AddressConstraints SINGLE_DIMENSION = + new AddressConstraints(Integer.MAX_VALUE, 1, false); + + public AddressConstraints { + if (maxIndex < 0) { + throw new IllegalArgumentException("maxIndex must not be negative"); + } + if (maxDimensions < 1) { + throw new IllegalArgumentException("maxDimensions must be at least 1"); + } + } + + public AddressConstraints withMaxIndex(int maxIndex) { + return new AddressConstraints(maxIndex, maxDimensions, onlyTrailingDimensionMayBeRange); + } + + public AddressConstraints withMaxDimensions(int maxDimensions) { + return new AddressConstraints(maxIndex, maxDimensions, onlyTrailingDimensionMayBeRange); + } + + public AddressConstraints withOnlyTrailingDimensionMayBeRange(boolean onlyTrailing) { + return new AddressConstraints(maxIndex, maxDimensions, onlyTrailing); + } +} diff --git a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/ArrayNotationParser.java b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/ArrayNotationParser.java new file mode 100644 index 00000000000..84c86125b87 --- /dev/null +++ b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/ArrayNotationParser.java @@ -0,0 +1,301 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.spi.drivers.model; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.api.model.ArrayInfo; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The array notation shared by every PLC4X tag address. + * + *

+ * array-expression = dimension , { dimension } ;
+ * dimension        = "[" , bounds , [ ";" , base ] , "]" ;
+ * bounds           = index , [ ".." , index ] ;
+ * 
+ * + *

A range is inclusive of both bounds, so {@code [0..7]} is eight elements. A bare index is + * one element, so {@code [4]} is the fifth. {@code ;base} states the array's declared lower + * bound - {@code [4..7;1]} selects elements 4 to 7 of an array declared from 1, which sit at + * offsets 3 to 6 - and defaults to 0. Each bracket group is one dimension, in written order. + * + *

This class is the single definition of that grammar. Drivers differ in what they can encode, + * not in what they accept, so a driver states its limits in {@link AddressConstraints} and a + * selection exceeding them is reported here rather than truncated when the request is serialized. + * + *

Patterns are compiled once: address parsing runs per tag per request in some drivers. + */ +public final class ArrayNotationParser { + + /** One dimension: an index or an inclusive range, with an optional declared lower bound. */ + private static final String DIMENSION = "\\d+(?:\\.\\.\\d+)?(?:;\\d+)?"; + + /** + * The array expression, as a regex fragment with no capturing groups. A driver embeds this in + * its own address pattern so that the grammar has one definition rather than a copy per + * driver - see {@link #ARRAY_GROUP}. + */ + public static final String EXPRESSION_REGEX = "(?:\\[" + DIMENSION + "(?:," + DIMENSION + ")*])+"; + + /** + * The array expression as an optional named group, ready to splice into a driver's address + * pattern between the address and the type. The group is named {@code array}. + */ + public static final String ARRAY_GROUP = "(?" + EXPRESSION_REGEX + ")?"; + + /** A trailing run of strictly numeric bracket groups. */ + private static final Pattern EXPRESSION_PATTERN = Pattern.compile(EXPRESSION_REGEX + "$"); + + /** One bracket group, whose content is one or more comma-separated dimensions. */ + private static final Pattern GROUP_PATTERN = Pattern.compile("\\[([^\\]]*)]"); + + private static final Pattern DIMENSION_PATTERN = + Pattern.compile("(\\d+)(?:\\.\\.(\\d+))?(?:;(\\d+))?"); + + private ArrayNotationParser() { + } + + /** + * An address in the pre-migration shape {@code address:TYPE[n]}, where the selection came + * after the type and meant a count. + */ + private static final Pattern LEGACY_AFTER_TYPE = Pattern.compile( + "^(?.+?):(?[A-Za-z_][A-Za-z_0-9]*(?:\\(\\d+\\))?)\\[(?\\d+)]$"); + + /** An address in the pre-migration shape {@code address:TYPE:n}, where a count trailed it. */ + private static final Pattern LEGACY_COUNT_SUFFIX = Pattern.compile( + "^(?.+?):(?[A-Za-z_][A-Za-z_0-9]*)(?::(?\\d+))$"); + + /** + * How to rewrite an address that was written before the array notation was unified, or empty + * when it does not look like one. + * + *

The brackets moved from after the type to before it, and a count became a range. An + * upgrading user who sees only "does not match pattern" has to work that out from a regex; + * this hands them the address they meant. + * + * @param address the address that failed to parse + * @return the equivalent address in the current notation, if one can be worked out + */ + public static Optional currentFormOf(String address) { + if (address == null) { + return Optional.empty(); + } + Matcher afterType = LEGACY_AFTER_TYPE.matcher(address); + if (afterType.matches()) { + return Optional.of(afterType.group("head") + + rangeFor(afterType.group("count")) + ":" + afterType.group("type")); + } + Matcher countSuffix = LEGACY_COUNT_SUFFIX.matcher(address); + if (countSuffix.matches()) { + return Optional.of(countSuffix.group("head") + + rangeFor(countSuffix.group("count")) + ":" + countSuffix.group("type")); + } + return Optional.empty(); + } + + private static String rangeFor(String count) { + int elements = Integer.parseInt(count); + return elements <= 1 ? "[0]" : "[0.." + (elements - 1) + "]"; + } + + /** + * Reports an address the driver could not parse, naming the form it expected and - when the + * address looks like one written before the notation was unified - the address to write + * instead. + */ + public static PlcInvalidTagException invalidAddress(String address, String expectedForm) { + String message = "Invalid address '" + address + "': expected " + expectedForm; + Optional current = currentFormOf(address); + if (current.isPresent()) { + message += ". The array notation moved before the type and a count became a range," + + " so this address is now written '" + current.get() + "'"; + } + return new PlcInvalidTagException(message); + } + + /** + * The part of an address before any trailing array expression. An address with no such + * expression is returned unchanged - including one whose brackets are not numeric, such as an + * OPC UA string identifier that happens to contain them. + */ + public static String addressPart(String address) { + Matcher matcher = EXPRESSION_PATTERN.matcher(address); + return matcher.find() ? address.substring(0, matcher.start()) : address; + } + + /** The trailing array expression of an address, or the empty string if it has none. */ + public static String expressionPart(String address) { + Matcher matcher = EXPRESSION_PATTERN.matcher(address); + return matcher.find() ? matcher.group() : ""; + } + + /** + * Whether an expression selects a single element rather than an array, which decides what a + * tag reports from {@code getArrayInfo()}: a bare index yields a scalar, a range yields an + * array even when it spans one element. {@code [1]} is a scalar and {@code [1..1]} is an + * array of one, so equal bounds alone cannot tell them apart - only the written form can. + * + *

An expression is a single element when every one of its dimensions is a bare index. + */ + public static boolean selectsSingleElement(String expression) { + return expression != null && !expression.isEmpty() && !expression.contains(".."); + } + + /** Parses an array expression with no constraints beyond the grammar. */ + public static List parse(String expression, String address) { + return parse(expression, address, AddressConstraints.UNCONSTRAINED); + } + + /** + * Parses an array expression into one {@link ArrayInfo} per dimension, in written order. + * + * @param expression the bracket run, or empty for an address that selects no range + * @param address the whole address, quoted back in any error so the user can find it + * @param constraints what the calling driver's protocol can encode + * @throws PlcInvalidTagException if the expression is malformed or exceeds the constraints + */ + public static List parse(String expression, String address, AddressConstraints constraints) { + if (expression == null || expression.isEmpty()) { + return Collections.emptyList(); + } + if (!EXPRESSION_PATTERN.matcher(expression).matches()) { + throw new PlcInvalidTagException(String.format( + "Invalid array expression '%s' in tag '%s': expected [index], [lo..hi] or either " + + "with a ';base', repeated once per dimension", expression, address)); + } + + List dimensions = new ArrayList<>(2); + Matcher group = GROUP_PATTERN.matcher(expression); + while (group.find()) { + // A group may hold several dimensions separated by commas - the spelling Allen-Bradley + // and others use. "[1..2,3..4]" and "[1..2][3..4]" are the same selection. + for (String part : group.group(1).split(",")) { + Matcher dimension = DIMENSION_PATTERN.matcher(part); + if (!dimension.matches()) { + throw new PlcInvalidTagException(String.format( + "Invalid array dimension '%s' in tag '%s'", part, address)); + } + dimensions.add(toDimension(dimension, address, constraints)); + } + } + + if (dimensions.size() > constraints.maxDimensions()) { + throw new PlcInvalidTagException(String.format( + "Array expression '%s' in tag '%s' has %d dimensions, but this protocol carries " + + "at most %d", expression, address, dimensions.size(), constraints.maxDimensions())); + } + if (constraints.onlyTrailingDimensionMayBeRange()) { + for (int i = 0; i < dimensions.size() - 1; i++) { + // What matters is how the dimension was written, not how wide it turned out to + // be: "[1..1]" is a range that happens to span one element, and letting it pass + // here would hand the driver a leading range it has no element count for. + if (dimensions.get(i).isRange()) { + throw new PlcInvalidTagException(String.format( + "Array expression '%s' in tag '%s' writes dimension %d as a range, but " + + "this protocol carries one element count for the whole address, so " + + "only the last dimension may be a range", + expression, address, i + 1)); + } + } + } + return Collections.unmodifiableList(dimensions); + } + + private static ArrayInfo toDimension(Matcher bracket, String address, AddressConstraints constraints) { + int lowerBound = parseIndex(bracket.group(1), bracket.group(), address); + boolean range = bracket.group(2) != null; + int upperBound = range ? parseIndex(bracket.group(2), bracket.group(), address) : lowerBound; + int base = bracket.group(3) != null + ? parseIndex(bracket.group(3), bracket.group(), address) + : 0; + + if (upperBound < lowerBound) { + throw new PlcInvalidTagException(String.format( + "Invalid array range '%s' in tag '%s': the upper bound %d is below the lower bound %d", + bracket.group(), address, upperBound, lowerBound)); + } + // The inclusive size is computed in an int, so a range spanning more than Integer.MAX_VALUE + // elements would wrap to a negative count and be reported as a syntactically valid + // selection of minus two billion elements. No protocol here can carry such a range anyway. + if (((long) upperBound - lowerBound + 1) > Integer.MAX_VALUE) { + throw new PlcInvalidTagException(String.format( + "Invalid array range '%s' in tag '%s': it spans %d elements, more than can be counted", + bracket.group(), address, (long) upperBound - lowerBound + 1)); + } + if (lowerBound - base < 0) { + throw new PlcInvalidTagException(String.format( + "Invalid array range '%s' in tag '%s': index %d lies below the declared lower bound %d", + bracket.group(), address, lowerBound, base)); + } + // The bound applies to the offset the protocol actually encodes - the start of the + // selection - not to the last element of it. A CIP request carries a start index and an + // element count, so [0..300] is encodable where [300] is not. + if (lowerBound - base > constraints.maxIndex()) { + throw new PlcInvalidTagException(String.format( + "Invalid array range '%s' in tag '%s': index %d is out of range 0 to %d for this protocol", + bracket.group(), address, lowerBound - base, constraints.maxIndex())); + } + return new DefaultArrayInfo(lowerBound, upperBound, base, range); + } + + private static int parseIndex(String value, String bracket, String address) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new PlcInvalidTagException(String.format( + "Invalid array range '%s' in tag '%s': '%s' is not a number this protocol can address", + bracket, address, value)); + } + } + + /** + * Renders dimensions back to their canonical form: one bracket per dimension, omitting what + * is defaulted - a base of 0 is dropped, and equal bounds render as a bare index. The + * comma-separated spelling is accepted on input but never produced, so + * {@code [1..2,3..4]} renders as {@code [1..2][3..4]}. Parsing the result yields equal + * dimensions, so an address round-trips by meaning rather than by spelling. + */ + public static String render(List dimensions) { + if (dimensions == null || dimensions.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (ArrayInfo dimension : dimensions) { + sb.append('[').append(dimension.getLowerBound()); + if (dimension.isRange()) { + // A one-element range still renders as a range: [8..8] is an array of one and + // [8] is a scalar, so collapsing it would change what the address means. + sb.append("..").append(dimension.getUpperBound()); + } + if (dimension.getBase() != 0) { + sb.append(';').append(dimension.getBase()); + } + sb.append(']'); + } + return sb.toString(); + } +} diff --git a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/DefaultArrayInfo.java b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/DefaultArrayInfo.java index 952cb99b8f3..b33b078c85d 100644 --- a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/DefaultArrayInfo.java +++ b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/DefaultArrayInfo.java @@ -24,10 +24,27 @@ public class DefaultArrayInfo implements ArrayInfo { private final int lowerBound; private final int upperBound; + private final int base; + private final boolean range; + /** An array declared from 0, which is every array that does not say otherwise. */ public DefaultArrayInfo(int lowerBound, int upperBound) { + this(lowerBound, upperBound, 0); + } + + public DefaultArrayInfo(int lowerBound, int upperBound, int base) { + this(lowerBound, upperBound, base, lowerBound != upperBound); + } + + /** + * @param range whether the address wrote this dimension as a range. A one-element range is + * still a range - see {@link ArrayInfo#isRange()}. + */ + public DefaultArrayInfo(int lowerBound, int upperBound, int base, boolean range) { this.lowerBound = lowerBound; this.upperBound = upperBound; + this.base = base; + this.range = range; } @Override @@ -45,4 +62,44 @@ public int getUpperBound() { return upperBound; } + @Override + public int getBase() { + return base; + } + + @Override + public boolean isRange() { + return range; + } + + /** The offset of the first selected element from the start of the array. */ + public int getOffset() { + return lowerBound - base; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DefaultArrayInfo other)) { + return false; + } + return lowerBound == other.lowerBound && upperBound == other.upperBound + && base == other.base && range == other.range; + } + + @Override + public int hashCode() { + return java.util.Objects.hash(lowerBound, upperBound, base, range); + } + + @Override + public String toString() { + return "DefaultArrayInfo{lowerBound=" + lowerBound + + ", upperBound=" + upperBound + + ", base=" + base + + ", range=" + range + '}'; + } + } diff --git a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/model/ArrayNotationParserTest.java b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/model/ArrayNotationParserTest.java new file mode 100644 index 00000000000..8ac53463e99 --- /dev/null +++ b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/model/ArrayNotationParserTest.java @@ -0,0 +1,360 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.spi.drivers.model; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.api.model.ArrayInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The one definition of the array notation, per + * {@code specs/002-unified-array-notation/contracts/array-notation-grammar.md}. + * + *

Every row of that contract's semantics table and every row of its rejection table has a case + * here. The grammar is shared by every driver, so a gap here is a gap everywhere. + */ +class ArrayNotationParserTest { + + // --- the semantics table --- + + @ParameterizedTest(name = "{0} -> {1} element(s) at offset {2}..{3}") + @CsvSource({ + // expression, size, firstOffset, lastOffset + "'[4]', 1, 4, 4", + "'[0..7]', 8, 0, 7", + "'[4;1]', 1, 3, 3", + "'[4..7;1]', 4, 3, 6", + "'[0]', 1, 0, 0", + "'[7..7]', 1, 7, 7", + }) + void singleDimensionResolvesToTheDocumentedOffsets(String expression, int size, int first, int last) { + List dimensions = ArrayNotationParser.parse(expression, "tag" + expression); + + assertEquals(1, dimensions.size()); + ArrayInfo only = dimensions.get(0); + assertEquals(size, only.getSize(), "size"); + assertEquals(first, only.getLowerBound() - only.getBase(), "first offset"); + assertEquals(last, only.getUpperBound() - only.getBase(), "last offset"); + } + + /** + * A bare index and a one-element range cover the same element but are not the same selection: + * the first yields a scalar and the second an array of one. Equal bounds cannot tell them + * apart, so the written form is carried on the dimension. + */ + @Test + void aBareIndexIsNotTheSameAsAOneElementRange() { + List index = ArrayNotationParser.parse("[4]", "tag[4]"); + List range = ArrayNotationParser.parse("[4..4]", "tag[4..4]"); + + assertNotEquals(range, index); + assertFalse(index.get(0).isRange()); + assertTrue(range.get(0).isRange()); + assertEquals(index.get(0).getLowerBound(), range.get(0).getLowerBound()); + assertEquals(1, index.get(0).getSize()); + assertEquals(1, range.get(0).getSize()); + } + + @Test + void writtenBoundsArePreservedNotResolved() { + ArrayInfo dimension = ArrayNotationParser.parse("[4..7;1]", "tag[4..7;1]").get(0); + + assertEquals(4, dimension.getLowerBound(), "lower bound is as written"); + assertEquals(7, dimension.getUpperBound(), "upper bound is as written"); + assertEquals(1, dimension.getBase(), "declared base"); + assertEquals(4, dimension.getSize()); + } + + @Test + void baseDefaultsToZero() { + assertEquals(0, ArrayNotationParser.parse("[4]", "tag[4]").get(0).getBase()); + } + + @Test + void multipleDimensionsKeepTheirWrittenOrder() { + List dimensions = ArrayNotationParser.parse("[1..2][0..5]", "tag[1..2][0..5]"); + + assertEquals(2, dimensions.size()); + assertEquals(1, dimensions.get(0).getLowerBound()); + assertEquals(2, dimensions.get(0).getUpperBound()); + assertEquals(0, dimensions.get(1).getLowerBound()); + assertEquals(5, dimensions.get(1).getUpperBound()); + } + + @Test + void eachDimensionCarriesItsOwnBase() { + List dimensions = + ArrayNotationParser.parse("[4..7;1][7..10;2]", "tag[4..7;1][7..10;2]"); + + assertEquals(2, dimensions.size()); + assertEquals(3, dimensions.get(0).getLowerBound() - dimensions.get(0).getBase()); + assertEquals(6, dimensions.get(0).getUpperBound() - dimensions.get(0).getBase()); + assertEquals(5, dimensions.get(1).getLowerBound() - dimensions.get(1).getBase()); + assertEquals(8, dimensions.get(1).getUpperBound() - dimensions.get(1).getBase()); + } + + // --- the rejection table --- + + @ParameterizedTest + @ValueSource(strings = { + "[]", // no index + "[7..4]", // upper below lower + "[0;1]", // resolved offset negative + "[-1]", // negative component + "[1..-2]", // negative component + "[a]", // non-numeric + "[1..x]", // non-numeric + "[1..2;]", // empty base + "[1..]", // missing upper bound + "[..2]", // missing lower bound + }) + void malformedExpressionsAreRejected(String expression) { + PlcInvalidTagException thrown = assertThrows(PlcInvalidTagException.class, + () -> ArrayNotationParser.parse(expression, "tag" + expression)); + assertTrue(thrown.getMessage().contains("tag" + expression), + () -> "message should name the address: " + thrown.getMessage()); + } + + // --- driver constraints --- + + @Test + void indexBeyondTheProtocolMaximumIsRejected() { + AddressConstraints eip = AddressConstraints.SINGLE_DIMENSION.withMaxIndex(255); + + assertDoesNotThrow(() -> ArrayNotationParser.parse("[255]", "tag[255]", eip)); + // The bound is on where the selection starts, not where it ends: a CIP request carries a + // start index and an element count, so a long run from an encodable start is fine. + assertDoesNotThrow(() -> ArrayNotationParser.parse("[0..300]", "tag[0..300]", eip)); + assertDoesNotThrow(() -> ArrayNotationParser.parse("[256;1]", "tag[256;1]", eip)); + + PlcInvalidTagException thrown = assertThrows(PlcInvalidTagException.class, + () -> ArrayNotationParser.parse("[256]", "tag[256]", eip)); + assertTrue(thrown.getMessage().contains("255"), + () -> "message should name the real bound: " + thrown.getMessage()); + } + + @Test + void moreDimensionsThanTheProtocolCarriesIsRejected() { + PlcInvalidTagException thrown = assertThrows(PlcInvalidTagException.class, + () -> ArrayNotationParser.parse("[1][2]", "tag[1][2]", AddressConstraints.SINGLE_DIMENSION)); + assertTrue(thrown.getMessage().contains("1"), thrown::getMessage); + } + + @Test + void interiorRangeIsRejectedWhereOnlyTheTrailingDimensionMaySpan() { + AddressConstraints trailingOnly = AddressConstraints.UNCONSTRAINED + .withOnlyTrailingDimensionMayBeRange(true); + + assertDoesNotThrow(() -> ArrayNotationParser.parse("[1][0..3]", "tag[1][0..3]", trailingOnly)); + assertThrows(PlcInvalidTagException.class, + () -> ArrayNotationParser.parse("[0..3][1]", "tag[0..3][1]", trailingOnly)); + + // A one-element range is still a range. Judging this by the span let "[1..1][2]" through, + // handing the driver a leading range it has no element count for. + assertThrows(PlcInvalidTagException.class, + () -> ArrayNotationParser.parse("[1..1][2]", "tag[1..1][2]", trailingOnly)); + + // A single index in the same position stays legal, which is what the constraint is for. + assertDoesNotThrow(() -> ArrayNotationParser.parse("[1][2]", "tag[1][2]", trailingOnly)); + } + + // --- splitting an address --- + + @ParameterizedTest(name = "{0} -> address {1}, expression {2}") + @CsvSource({ + "'myTag[0..7]', 'myTag', '[0..7]'", + "'myTag', 'myTag', ''", + "'a.b[2]', 'a.b', '[2]'", + "'40001[0..3]', '40001', '[0..3]'", + "'t[1..2][0..5]', 't', '[1..2][0..5]'", + }) + void trailingExpressionIsSplitFromTheAddress(String input, String address, String expression) { + assertEquals(address, ArrayNotationParser.addressPart(input)); + assertEquals(expression, ArrayNotationParser.expressionPart(input)); + } + + /** + * Only a strictly numeric trailing run counts. OPC UA string identifiers may legitimately + * contain brackets, and those must be left on the address. + */ + @Test + void nonNumericBracketsAreNotAnArrayExpression() { + assertEquals("Some[Node]Name", ArrayNotationParser.addressPart("Some[Node]Name")); + assertEquals("", ArrayNotationParser.expressionPart("Some[Node]Name")); + } + + // --- rendering back --- + + @ParameterizedTest + @ValueSource(strings = {"[4]", "[0..7]", "[4;1]", "[4..7;1]", "[1..2][0..5]", "[4..7;1][7..10;2]"}) + void renderingReproducesTheCanonicalForm(String expression) { + assertEquals(expression, ArrayNotationParser.render(ArrayNotationParser.parse(expression, "tag"))); + } + + /** + * Canonical form omits what is defaulted - a base of 0 - but never the range form, because + * dropping that would turn an array of one into a scalar. + */ + @Test + void canonicalFormOmitsDefaultsButKeepsTheRangeForm() { + assertEquals("[4..4]", ArrayNotationParser.render(ArrayNotationParser.parse("[4..4;0]", "tag"))); + assertEquals("[4]", ArrayNotationParser.render(ArrayNotationParser.parse("[4;0]", "tag"))); + assertEquals("[0..7]", ArrayNotationParser.render(ArrayNotationParser.parse("[0..7;0]", "tag"))); + } + + // --- guidance for addresses written before the migration --- + + @ParameterizedTest(name = "{0} -> {1}") + @CsvSource({ + "'holding-register:1:INT[4]', 'holding-register:1[0..3]:INT'", + "'%DB42:28.0:BYTE[8]', '%DB42:28.0[0..7]:BYTE'", + "'%DB1:0:STRING(40)[3]', '%DB1:0[0..2]:STRING(40)'", + "'D100:WORD[2]', 'D100[0..1]:WORD'", + "'0x4020/0:DINT[4]', '0x4020/0[0..3]:DINT'", + "'myTag:DINT:8', 'myTag[0..7]:DINT'", + "'foo:INT[1]', 'foo[0]:INT'", + }) + void anAddressWrittenBeforeTheMigrationIsRewritten(String legacy, String current) { + assertEquals(current, ArrayNotationParser.currentFormOf(legacy).orElse(null), legacy); + } + + @ParameterizedTest + @ValueSource(strings = {"myTag", "myTag[0..3]:DINT", "holding-register:1[0..3]:INT", "nonsense"}) + void anAddressThatIsNotInTheOldShapeGetsNoRewrite(String address) { + assertTrue(ArrayNotationParser.currentFormOf(address).isEmpty(), address); + } + + @Test + void theErrorNamesBothTheExpectedFormAndTheRewrite() { + PlcInvalidTagException thrown = + ArrayNotationParser.invalidAddress("holding-register:1:INT[4]", "{address}[range]:{TYPE}"); + + assertTrue(thrown.getMessage().contains("holding-register:1:INT[4]"), thrown::getMessage); + assertTrue(thrown.getMessage().contains("{address}[range]:{TYPE}"), thrown::getMessage); + assertTrue(thrown.getMessage().contains("holding-register:1[0..3]:INT"), thrown::getMessage); + } + + @Test + void anErrorForSomethingElseJustNamesTheExpectedForm() { + PlcInvalidTagException thrown = ArrayNotationParser.invalidAddress("nonsense", "{address}"); + assertFalse(thrown.getMessage().contains("now written"), thrown::getMessage); + } + + // --- the comma spelling --- + + /** + * Allen-Bradley and others write the dimensions of one array inside a single bracket. It is + * the same selection, so it parses the same - and renders back in the one canonical form. + */ + @ParameterizedTest(name = "{0} == {1}") + @CsvSource({ + "'[0,1]', '[0][1]'", + "'[1..2,3..4]', '[1..2][3..4]'", + "'[1..2;1,3..4;1]', '[1..2;1][3..4;1]'", + "'[0,1,2]', '[0][1][2]'", + "'[1..2,3]', '[1..2][3]'", + }) + void theCommaSpellingIsTheSameSelection(String comma, String brackets) { + assertEquals( + ArrayNotationParser.parse(brackets, "tag" + brackets), + ArrayNotationParser.parse(comma, "tag" + comma)); + } + + @ParameterizedTest(name = "{0} -> {1}") + @CsvSource({ + "'[0,1]', '[0][1]'", + "'[1..2,3..4]', '[1..2][3..4]'", + "'[0][1]', '[0][1]'", + }) + void renderingAlwaysProducesOneBracketPerDimension(String written, String canonical) { + assertEquals(canonical, + ArrayNotationParser.render(ArrayNotationParser.parse(written, "tag"))); + } + + @Test + void aMixtureOfBothSpellingsIsAccepted() { + assertEquals("[0][1][2]", + ArrayNotationParser.render(ArrayNotationParser.parse("[0,1][2]", "tag"))); + } + + @ParameterizedTest + @ValueSource(strings = {"[0,]", "[,1]", "[0,,1]", "[0, 1]"}) + void aMalformedCommaListIsRejected(String expression) { + assertThrows(PlcInvalidTagException.class, + () -> ArrayNotationParser.parse(expression, "tag" + expression)); + } + + /** The address split has to see the comma spelling as an array expression too. */ + @Test + void theCommaSpellingIsSplitFromTheAddress() { + assertEquals("myTag", ArrayNotationParser.addressPart("myTag[1..2,3..4]")); + assertEquals("[1..2,3..4]", ArrayNotationParser.expressionPart("myTag[1..2,3..4]")); + } + + // --- what the caller receives --- + + /** + * getArrayInfo() describes the value the caller gets, so a consumer can decide from it alone + * whether to render a scalar or a list. A bare index is a scalar; a range is an array even + * when it spans a single element. + */ + @ParameterizedTest + @CsvSource({ + "'[1]', true", + "'[4]', true", + "'[4;1]', true", + "'[1][2]', true", + "'[1..1]', false", + "'[0..7]', false", + "'[4..7;1]', false", + "'[1][0..5]', false", + "'', false", + }) + void aBareIndexSelectsAScalarButARangeDoesNot(String expression, boolean scalar) { + assertEquals(scalar, ArrayNotationParser.selectsSingleElement(expression), expression); + } + + @Test + void anAbsentExpressionRendersAsNothing() { + assertEquals("", ArrayNotationParser.render(List.of())); + assertTrue(ArrayNotationParser.parse("", "tag").isEmpty()); + } + + @Test + void refusesARangeThatCannotBeCounted() { + // getSize() is inclusive and computed in an int, so [0..2147483647] would wrap to a + // negative element count from a selection the syntax accepted. + PlcInvalidTagException thrown = assertThrows(PlcInvalidTagException.class, + () -> ArrayNotationParser.parse("[0..2147483647]", "%test[0..2147483647]", + AddressConstraints.UNCONSTRAINED)); + assertTrue(thrown.getMessage().contains("more than can be counted"), thrown.getMessage()); + + // One below the limit still parses, and counts what it says. + assertEquals(Integer.MAX_VALUE, ArrayNotationParser + .parse("[0..2147483646]", "%test[0..2147483646]", AddressConstraints.UNCONSTRAINED) + .get(0).getSize()); + } +} diff --git a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/model/ArrayNotationRoundTripTest.java b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/model/ArrayNotationRoundTripTest.java new file mode 100644 index 00000000000..c936be35668 --- /dev/null +++ b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/model/ArrayNotationRoundTripTest.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.spi.drivers.model; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Rendering a selection and parsing it back yields the same selection (SC-006). + * + *

The spelling may normalise - a base of 0 is dropped, the comma form becomes one bracket per + * dimension - but never the meaning, so a second pass is a fixed point. + */ +class ArrayNotationRoundTripTest { + + @ParameterizedTest + @ValueSource(strings = { + "[4]", "[0..7]", "[4;1]", "[4..7;1]", "[0]", "[7..7]", + "[1..2][0..5]", "[4..7;1][7..10;2]", "[0][1][2]", + "[0,1]", "[1..2,3..4]", "[0,1][2]", "[1..2;1,3..4;1]", + }) + void aSelectionSurvivesBeingRenderedAndParsedAgain(String written) { + var parsed = ArrayNotationParser.parse(written, "tag" + written); + var reparsed = ArrayNotationParser.parse( + ArrayNotationParser.render(parsed), "tag"); + + assertEquals(parsed, reparsed, written); + } + + /** Rendering is a fixed point: the canonical form renders to itself. */ + @ParameterizedTest + @ValueSource(strings = {"[4]", "[0..7]", "[4..7;1]", "[1..2][0..5]", "[0,1]", "[1..2,3..4]"}) + void renderingTheCanonicalFormChangesNothingFurther(String written) { + String once = ArrayNotationParser.render(ArrayNotationParser.parse(written, "tag")); + String twice = ArrayNotationParser.render(ArrayNotationParser.parse(once, "tag")); + + assertEquals(once, twice, written); + } + + /** The scalar/array distinction survives the trip, which is what FR-024 turns on. */ + @ParameterizedTest + @ValueSource(strings = {"[4]", "[4..4]", "[0..7]", "[0,1]", "[0..0,1..1]"}) + void whetherEachDimensionWasARangeSurvives(String written) { + var parsed = ArrayNotationParser.parse(written, "tag"); + var reparsed = ArrayNotationParser.parse(ArrayNotationParser.render(parsed), "tag"); + + for (int i = 0; i < parsed.size(); i++) { + assertEquals(parsed.get(i).isRange(), reparsed.get(i).isRange(), + written + " dimension " + i); + } + } +} diff --git a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/model/MultiDimensionConstraintTest.java b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/model/MultiDimensionConstraintTest.java new file mode 100644 index 00000000000..e3be82a694a --- /dev/null +++ b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/model/MultiDimensionConstraintTest.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.spi.drivers.model; + +import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException; +import org.apache.plc4x.java.api.model.ArrayInfo; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Multi-dimensional selections, and what happens on a protocol that cannot express them. + * + *

A driver addressing linear memory has no second dimension to carry, so it says so rather + * than quietly reading the first one. + */ +class MultiDimensionConstraintTest { + + @Test + void dimensionsComeBackInTheOrderTheyWereWritten() { + List dimensions = ArrayNotationParser.parse("[1..2][0..5]", "myTag[1..2][0..5]"); + + assertEquals(2, dimensions.size()); + assertEquals(1, dimensions.get(0).getLowerBound()); + assertEquals(2, dimensions.get(0).getUpperBound()); + assertEquals(0, dimensions.get(1).getLowerBound()); + assertEquals(5, dimensions.get(1).getUpperBound()); + } + + @Test + void theCommaSpellingYieldsTheSameOrder() { + assertEquals( + ArrayNotationParser.parse("[1..2][0..5]", "t"), + ArrayNotationParser.parse("[1..2,0..5]", "t")); + } + + /** The limit is named, so the user learns it is the protocol and not the syntax. */ + @Test + void aSecondDimensionIsRefusedWhereTheProtocolCarriesOne() { + PlcInvalidTagException thrown = assertThrows(PlcInvalidTagException.class, + () -> ArrayNotationParser.parse("[1..2][0..5]", "myTag[1..2][0..5]", + AddressConstraints.SINGLE_DIMENSION)); + + assertTrue(thrown.getMessage().contains("2 dimensions"), thrown::getMessage); + assertTrue(thrown.getMessage().contains("at most 1"), thrown::getMessage); + } + + @Test + void theCommaSpellingIsRefusedTheSameWay() { + assertThrows(PlcInvalidTagException.class, + () -> ArrayNotationParser.parse("[1..2,0..5]", "myTag[1..2,0..5]", + AddressConstraints.SINGLE_DIMENSION)); + } + + /** One dimension is fine on such a driver, however it is spelled. */ + @Test + void oneDimensionIsStillAccepted() { + assertDoesNotThrow(() -> ArrayNotationParser.parse("[0..5]", "myTag[0..5]", + AddressConstraints.SINGLE_DIMENSION)); + } + + /** An omitted selection is no dimensions at all, which every protocol can carry. */ + @Test + void anOmittedSelectionIsNotADimension() { + assertTrue(ArrayNotationParser.parse("", "myTag", AddressConstraints.SINGLE_DIMENSION).isEmpty()); + } +} diff --git a/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapFilePlayerTest.java b/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapFilePlayerTest.java index 7dbbfc239de..762e1a20867 100644 --- a/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapFilePlayerTest.java +++ b/plc4j/transports/pcap-replay/src/test/java/org/apache/plc4x/java/transport/pcapreplay/PcapFilePlayerTest.java @@ -452,19 +452,34 @@ void testPlayback_withTestPcap() throws Exception { ); player.start(); - assertTrue(player.isPlaying()); - // Wait for replay to complete - Thread.sleep(200); + // No isPlaying() check here: test.pcap is 328 bytes and this player replays it once, as + // fast as it can, so playback can be over before the next statement runs - the flag says + // more about thread scheduling than about the player. (Every other test in this class + // that asserts isPlaying() passes loop=true so the player cannot finish on its own; this + // one is deliberately a single pass.) What the replay did is the thing worth asserting. + awaitPlaybackEnd(player); - // Should have processed some packets - long packetsReplayed = player.getPacketsReplayed(); - assertTrue(packetsReplayed >= 0); + // Every packet in the file, rather than ">= 0", which a count can never fail. + assertEquals(4, player.getPacketsReplayed(), "all four packets in test.pcap"); player.stop(); assertFalse(player.isPlaying()); } + /** + * Waits for a single-pass replay to finish, rather than sleeping for a fixed time and hoping. + * A fixed sleep is either too short on a loaded machine - which is when this test used to + * fail - or slower than it needs to be on an idle one. + */ + private static void awaitPlaybackEnd(PcapFilePlayer player) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (player.isPlaying() && (System.nanoTime() < deadline)) { + Thread.sleep(5); + } + assertFalse(player.isPlaying(), "a single pass over a 328 byte file should be long done"); + } + @Test void testPlayback_withProtocolFilter() throws Exception { // Test with protocol filter diff --git a/protocols/eip/src/test/resources/protocols/eip/DriverTestsuite.xml b/protocols/eip/src/test/resources/protocols/eip/DriverTestsuite.xml index e69722f371d..25169cba249 100644 --- a/protocols/eip/src/test/resources/protocols/eip/DriverTestsuite.xml +++ b/protocols/eip/src/test/resources/protocols/eip/DriverTestsuite.xml @@ -1103,7 +1103,7 @@ hurz -

%rate:DINT:4
+
%rate[0..3]:DINT
diff --git a/protocols/modbus/src/test/resources/protocols/modbus/tcp/DriverTestsuite.xml b/protocols/modbus/src/test/resources/protocols/modbus/tcp/DriverTestsuite.xml index bca1b8f01db..cc6976df485 100644 --- a/protocols/modbus/src/test/resources/protocols/modbus/tcp/DriverTestsuite.xml +++ b/protocols/modbus/src/test/resources/protocols/modbus/tcp/DriverTestsuite.xml @@ -130,7 +130,7 @@ hurz -
holding-register:1:REAL[2]
+
holding-register:1[0..1]:REAL
@@ -482,7 +482,7 @@ hurz -
holding-register:1:REAL[2]
+
holding-register:1[0..1]:REAL
3.1415927 3.1415927
diff --git a/protocols/modbus/src/test/resources/protocols/modbus/tcp/DriverTestsuiteOptimized.xml b/protocols/modbus/src/test/resources/protocols/modbus/tcp/DriverTestsuiteOptimized.xml index 9be68785066..f5c36fc98ed 100644 --- a/protocols/modbus/src/test/resources/protocols/modbus/tcp/DriverTestsuiteOptimized.xml +++ b/protocols/modbus/src/test/resources/protocols/modbus/tcp/DriverTestsuiteOptimized.xml @@ -134,7 +134,7 @@ hurz -
holding-register:1:REAL[2]
+
holding-register:1[0..1]:REAL
@@ -452,7 +452,7 @@ hurz -
holding-register:1:REAL[2]
+
holding-register:1[0..1]:REAL
3.1415927 3.1415927
diff --git a/website/asciidoc/modules/users/nav.adoc b/website/asciidoc/modules/users/nav.adoc index 52c2c38fdd6..02422702671 100644 --- a/website/asciidoc/modules/users/nav.adoc +++ b/website/asciidoc/modules/users/nav.adoc @@ -33,6 +33,7 @@ ** xref:blogs-videos-and-slides.adoc[] +** xref:array-notation.adoc[Addressing arrays] ** xref:protocols/index.adoc[Protocols] *** xref:protocols/ab-eth.adoc[] *** xref:protocols/ads.adoc[] diff --git a/website/asciidoc/modules/users/pages/array-notation.adoc b/website/asciidoc/modules/users/pages/array-notation.adoc new file mode 100644 index 00000000000..b49a300d876 --- /dev/null +++ b/website/asciidoc/modules/users/pages/array-notation.adoc @@ -0,0 +1,199 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +:icons: font + += Addressing arrays + +Every PLC4X driver selects array elements the same way, in PLC4J and in PLC4Go alike. The address +itself differs per protocol - an S7 data block, a Modbus register, an OPC UA node - but what comes +after it does not: + +[source] +---- +[]: +---- + +The selection sits *before* the type. Where a driver's address carries no type, it ends the +address. + +The same address means the same thing in either language: one address, written once, used from +Java or Go. + +== The forms + +[cols="2,3,2", options="header"] +|=== +|Written |Means |Example + +|_(omitted)_ +|the whole value - a scalar, or every element of an array +|`myTag` + +|`[n]` +|**one element**, the one at index `n` +|`myTag[4]` - the fifth element + +|`[lo..hi]` +|an **array** of the elements `lo` through `hi`, both included +|`myTag[0..7]` - eight elements + +|`[n;base]` +|one element, in an array the PLC declares as starting at `base` +|`myTag[1;1]` - the first element + +|`[lo..hi;base]` +|a range, in an array declared as starting at `base` +|`myTag[4..7;1]` - four elements, starting at the fourth + +|`[…][…]` +|one bracket per dimension +|`myTag[1..2][0..5]` +|=== + +=== A single index is not an array + +`myTag[4]` selects one element and gives you a scalar. `myTag[4..4]` selects a range that happens +to hold one element and gives you a list of one. The difference is deliberate: it is how a tool +reading `getArrayInfo()` decides whether to render a value or a list. + +=== Arrays that do not start at zero + +IEC 61131 lets an array be declared `ARRAY[1..10] OF BYTE`, and the indices the PLC program shows +you then start at 1. Write those indices, and add `;base` so the driver knows where the data +really begins: + +[source] +---- +%DB42:28.0[4..7;1]:BYTE +---- + +That reads four bytes, starting three elements past the address - because element 4 of an array +declared from 1 is the fourth. + +Some drivers already know the declared bounds from the device: **ADS**, **UMAS** and **OPC-UA** +learn them from the symbol table or the address space. There you can simply write the declared +indices, and `;base` becomes a statement of intent the driver checks against what the device says. +A base that disagrees is reported, because it means the address was written against a different +layout than the PLC has. + +=== Multi-dimensional arrays + +Two spellings are accepted and mean the same thing - the second is what Allen-Bradley and others +use: + +[source] +---- +g_matI16_2x3[1..2][3..4] +g_matI16_2x3[1..2,3..4] +---- + +`getAddressString()` always produces the first, so an address you read back from a tag is in one +canonical spelling regardless of how it was written. + +A range may only be the **last** dimension. `a[1].b[2..5]` is fine - one path to one structure, +then a run of its elements. `a[1..3].b` is not: member `b` of three separate elements is not one +contiguous read, and no protocol here fetches that in a single request. The same goes for +`a[1..3][2]`, a strided slice. + +== What each driver can express + +The notation is the same everywhere; what a protocol can carry is not. + +[cols="2,1,1,3", options="header"] +|=== +|Driver |Dimensions |Whole array |Notes + +|OPC-UA |many |yes |Omitting the selection asks for the whole node +|ADS |many |yes |Declared bounds come from the symbol table +|UMAS |many |yes |Selecting elements is not implemented yet and is reported +|EtherNet/IP |1 |no |A CIP index is a `uint 8`, so it cannot start past 255 +|S7, Modbus, SLMP |1 |no |Memory offsets; the base is resolved into the address +|Simulated, Profinet |1 |no |Can only select from the first element +|Firmata |1 |no |No type suffix, so the selection ends the address +|KNXnet/IP |1 |no |Device addresses only; a property selection moves the start index +|=== + +Where a protocol cannot express what you wrote, the driver says so. It will not quietly read +something else. + +The two bindings do not carry the same set of drivers, and a driver present in both is not always +at the same depth. Where a driver in both accepts a selection, the notation and its meaning are +identical; what differs is which drivers accept one at all: + +* PLC4Go has no Profinet driver. +* PLC4Go's OPC-UA driver takes no selection - its addresses are a NodeId and a type. PLC4J's, + which is where this notation came from, takes the full grammar. +* PLC4Go's UMAS driver accepts a bare index inside a symbolic path (`myVar[1].member[2]`) but not + a range; PLC4J's parses ranges and reports selecting elements as unsupported. +* PLC4Go's KNXnet/IP driver has the device address forms in the table above. PLC4J's has no + equivalent. + +=== Brackets that are not array selections + +Three address forms use square brackets for something else entirely. They are unchanged by this +notation, and the notation's own spellings are not accepted there: + +* **C-Bus** writes the arguments of a CAL command in brackets - `recall=[param, count]`. The + count is an argument of the command, not a selection appended to an address. +* **KNXnet/IP group addresses** write a set of group addresses to match - `1/[2-3]/4`, and + `[1,3]/2/[4-6]`. That is a filter over addresses, not a selection within one value. +* **BACnet/IP** writes a property's array index - `ANALOG_VALUE,2/PRESENT_VALUE[3]`. This is an + index, and already means what an index means here, so nothing about it changed. + +== Why `;` and not something else + +The `;` before a declared base is the one separator the notation adds. It is kept as `;` for +three reasons: + +* OPC UA addresses already use `;` as the separator of the standard NodeId string form + (`ns=2;i=MyInt`, from OPC UA Part 6). That is not PLC4X's to redefine - people copy those + strings out of their tooling - so `;` was already part of the vocabulary. +* It stays visually distinct from `:`, which separates the type. `%DB42:28.0[4..7;1]:BYTE` is + readable; the same address with four colons doing three different jobs is not. +* `:` is structural in S7, Modbus, SLMP, EtherNet/IP and ADS addresses, whereas `;` is structural + in only one driver. Reusing the less overloaded character inside brackets keeps the grammar + unambiguous. + +== Upgrading + +The brackets used to come *after* the type on several drivers, where they meant a **count**: + +[source] +---- +holding-register:1:INT[4] <1> +holding-register:1[0..3]:INT <2> +---- +<1> before - four registers +<2> now + +An address in the old form no longer parses, and the error tells you what to write instead. That +is deliberate: `[4]` used to mean "four elements" and now means "the fifth", so an address that +still parsed would quietly return different data. + +WARNING: **Firmata is the exception.** Its addresses carry no type (`3[4]`), so its brackets did +not move and there is nothing to reject. `3[4]` used to mean four pins from pin 3 and now means +one pin, the fifth. Rewrite these as `3[0..3]`. + +WARNING: **PLC4Go's ADS driver is a second exception.** Its `[n]` used to be a *count* of `n` +elements, unlike PLC4J's, so `MAIN.g_arr[3]` read three elements and now reads one - the element +at index 3. Rewrite these as `MAIN.g_arr[0..2]`. Its `[a:b]` start-and-count form, which PLC4J +never had, is gone: write `[a..a+b-1]`. These two changes are also what makes the same ADS +address mean the same thing in both languages, which it did not before. + +A count of zero no longer has a spelling anywhere. Several drivers used to accept `[0]` and +reject it as "quantity must be greater than zero"; a range is written with the indices it covers, +so there is no way to ask for no elements at all, and `[0]` now selects the first one. diff --git a/website/asciidoc/modules/users/pages/protocols/ads.adoc b/website/asciidoc/modules/users/pages/protocols/ads.adoc index ccfe415254f..bd6ba7ac54c 100644 --- a/website/asciidoc/modules/users/pages/protocols/ads.adoc +++ b/website/asciidoc/modules/users/pages/protocols/ads.adoc @@ -32,6 +32,13 @@ ADS device concept: https://infosys.beckhoff.com/english.php?content=../content/ Specification for ADS devices: https://infosys.beckhoff.com/english.php?content=../content/1033/ams_nat/4275563275.html&id= Source (accessed 7 August 2022) == Structure AMS/TCP Packet + +[NOTE] +==== +Array selection uses the shared notation - a single index, an inclusive range, and +optionally the array's declared lower bound, placed before the type. See +xref:array-notation.adoc[Addressing arrays]. +==== ADS (Automation Device Specification) is the TwinCAT communication protocol that specifies the interaction between two ADS devices. For example, it defines what operations can be executed on another ADS device, what parameters are necessary for that and what return value is sent after execution. AMS (Automation Message Specification) specifies the exchange of the ADS data. A major component of the communication protocol is the AmsNetId. This is specified in the AMS/ADS package for the source and target device. An ADS device can be explicitly addressed using the AmsNetId. diff --git a/website/asciidoc/modules/users/pages/protocols/eip.adoc b/website/asciidoc/modules/users/pages/protocols/eip.adoc index 5f9c9f641a8..7a9f87e422b 100644 --- a/website/asciidoc/modules/users/pages/protocols/eip.adoc +++ b/website/asciidoc/modules/users/pages/protocols/eip.adoc @@ -23,6 +23,13 @@ == Connection String Options +[NOTE] +==== +Array selection uses the shared notation - a single index, an inclusive range, and +optionally the array's declared lower bound, placed before the type. See +xref:array-notation.adoc[Addressing arrays]. +==== + include::partial$eip.adoc[] == Address Format @@ -43,13 +50,12 @@ To read and write data to a PLC4X device, the EtherNet/IP driver uses symbolic s |=== |Name |Description |Tagname |symbolic name of the data. May optionally be prefixed with `%`. -|Start Index (optional)|if the data is an array, we can specify a starting index from where we want to read. It is appended to the tag name and has to be the last part of it. +|Selection (optional)|which elements of an array to read - a single index or an inclusive range. It follows the tag name, before the data type. See xref:array-notation.adoc[Addressing arrays]. |DataType (optional)|the data type of the value. Defaults to `DINT` when omitted, so for anything that is not a 32-bit integer it should be given explicitly - also when reading. -|Number of elements (optional)|if the data is an array, we can specify the number of elements we want to read. Defaults to `1`. Use this in combination with the starting index to get the exact scope you want. |=== -NOTE: The data type comes *before* the number of elements. Reading four DINTs starting at index 0 - is `myArray[0]:DINT:4`. +NOTE: The selection comes *before* the data type, and a range says how many elements are read. + Reading four DINTs starting at index 0 is `myArray[0..3]:DINT`. .Examples [cols="2" ,options="header"] @@ -57,11 +63,15 @@ NOTE: The data type comes *before* the number of elements. Reading four DINTs st |Address |Meaning |`myTag` |a single element of `myTag`, decoded as `DINT` |`myTag:REAL` |a single element of `myTag`, decoded as `REAL` -|`myTag:4` |four elements of `myTag`, decoded as `DINT` +|`myTag[0..3]` |four elements of `myTag`, decoded as `DINT` |`myArray[3]:DINT` |element 3 of `myArray` -|`myArray[0]:DINT:4` |elements 0 to 3 of `myArray`, returned as a list +|`myArray[0..3]:DINT` |elements 0 to 3 of `myArray`, returned as a list |=== +A CIP array index travels in a `MemberID`, whose instance field is a `uint 8`, so a selection +cannot start past index 255. A range may run beyond it - the request carries a start and a +count - but it cannot begin there. + === Data Types These are the data types the driver can encode and decode: diff --git a/website/asciidoc/modules/users/pages/protocols/firmata.adoc b/website/asciidoc/modules/users/pages/protocols/firmata.adoc index 2e29f572de8..1a9006581e1 100644 --- a/website/asciidoc/modules/users/pages/protocols/firmata.adoc +++ b/website/asciidoc/modules/users/pages/protocols/firmata.adoc @@ -29,6 +29,14 @@ This driver is built to be compatible with the `StandardFirmata Arduino Sketch` == Connection String Options +[WARNING] +==== +Firmata addresses carry no type, so the brackets did not move when the notation was +unified - and their meaning changed with nothing to reject. `3[4]` used to mean four pins +starting at pin 3; it now means one pin, the fifth. Rewrite these as `3[0..3]`. +See xref:array-notation.adoc[Addressing arrays]. +==== + include::partial$firmata.adoc[] [cols="2,2a,5a"] @@ -59,7 +67,7 @@ Booleans are used for the digital IO pins and short values for the analog inputs The full format for a digital address has the following format: ---- -digital:{start-address}[{array-size}]:{special-config} +digital:{start-address}[{selection}]:{special-config} ---- The `start-address` and `array-size` are simple integer values. @@ -84,7 +92,7 @@ WARNING: However in case of using the serial port (which will always be the case The full format for an analog address is as follows: ---- -analog:{start-address}[{array-size}] +analog:{start-address}[{selection}] ---- The `start-address` and `array-size` are simple integer values. diff --git a/website/asciidoc/modules/users/pages/protocols/modbus.adoc b/website/asciidoc/modules/users/pages/protocols/modbus.adoc index b86ae4aa313..19d6182e2bf 100644 --- a/website/asciidoc/modules/users/pages/protocols/modbus.adoc +++ b/website/asciidoc/modules/users/pages/protocols/modbus.adoc @@ -70,10 +70,15 @@ Note the transport, port and option fields are optional. In general all Modbus addresses have this format: ---- -{memory-Area}{start-address}:{data-type}[{array-size}]{name-value-tag-options} +{memory-Area}{start-address}[{selection}]:{data-type}{name-value-tag-options} ---- -If the array-size part is omitted, the size-default of `1` is assumed. +If the selection is omitted, a single element is read. +The selection in brackets is the shared array notation - a single index, an inclusive +range, and optionally the array's declared lower bound. See +xref:array-notation.adoc[Addressing arrays] for the full set of forms and what this +driver can express. + If the data-type part is omitted, it defaults to BOOL for Coils and Discrete Inputs and INT for input, holding and extended registers. If the name-value-tag-options part is omitted, simply no configuration fine-tuning is applied. @@ -184,7 +189,7 @@ NOTE: Coils and discrete inputs hold a single bit each and therefore support onl discrete-input tag declared with any other data type is accepted by the address parser, but the read returns the response code `UNSUPPORTED`. -Reading an array of coils returns all of its elements: `coil:1:BOOL[8]` yields a list of 8 values. +Reading an array of coils returns all of its elements: `coil:1[0..7]:BOOL` yields a list of 8 values. === Some useful tips diff --git a/website/asciidoc/modules/users/pages/protocols/opcua.adoc b/website/asciidoc/modules/users/pages/protocols/opcua.adoc index 527a8384d7e..ce6f797bb87 100644 --- a/website/asciidoc/modules/users/pages/protocols/opcua.adoc +++ b/website/asciidoc/modules/users/pages/protocols/opcua.adoc @@ -23,6 +23,13 @@ == Connection String Options +[NOTE] +==== +Array selection uses the shared notation - a single index, an inclusive range, and +optionally the array's declared lower bound, placed before the type. See +xref:array-notation.adoc[Addressing arrays]. +==== + include::partial$opcua.adoc[] [cols="2,2a,5a"] diff --git a/website/asciidoc/modules/users/pages/protocols/profinet.adoc b/website/asciidoc/modules/users/pages/protocols/profinet.adoc index 604b3badc1d..898248a7feb 100644 --- a/website/asciidoc/modules/users/pages/protocols/profinet.adoc +++ b/website/asciidoc/modules/users/pages/protocols/profinet.adoc @@ -30,6 +30,13 @@ on the same network segment. == Connection String Options +[NOTE] +==== +Array selection uses the shared notation - a single index, an inclusive range, and +optionally the array's declared lower bound, placed before the type. See +xref:array-notation.adoc[Addressing arrays]. +==== + On linux as the Java executable won't have permission to capture raw packets, this needs to be enabled via:- ---- sudo setcap cap_net_raw,cap_net_admin=eip /usr/lib/jvm/jdk-19/bin/java diff --git a/website/asciidoc/modules/users/pages/protocols/s7.adoc b/website/asciidoc/modules/users/pages/protocols/s7.adoc index 4a890e74fb8..e46f5b4f888 100644 --- a/website/asciidoc/modules/users/pages/protocols/s7.adoc +++ b/website/asciidoc/modules/users/pages/protocols/s7.adoc @@ -182,10 +182,15 @@ The PLC4X S7 Driver is therefore sticking to the address format defined by this In general all S7 addresses have this format: ---- -. %{Memory-Area}{start-address}:{Data-Type}[{array-size}] +. %{Memory-Area}{start-address}[{selection}]:{Data-Type} ---- -If the array-part is omitted, the size-default of `1` is assumed. +If the selection is omitted, a single element is read. +The selection in brackets is the shared array notation - a single index, an inclusive +range, and optionally the array's declared lower bound. See +xref:array-notation.adoc[Addressing arrays] for the full set of forms and what this +driver can express. + Generally there are two types of addresses: diff --git a/website/asciidoc/modules/users/pages/protocols/simulated.adoc b/website/asciidoc/modules/users/pages/protocols/simulated.adoc index ed4a024141d..5f7e5d7c741 100644 --- a/website/asciidoc/modules/users/pages/protocols/simulated.adoc +++ b/website/asciidoc/modules/users/pages/protocols/simulated.adoc @@ -23,6 +23,13 @@ == Connection String Options +[NOTE] +==== +Array selection uses the shared notation - a single index, an inclusive range, and +optionally the array's declared lower bound, placed before the type. See +xref:array-notation.adoc[Addressing arrays]. +==== + include::partial$simulated.adoc[] [cols="2,2a,5a"] diff --git a/website/asciidoc/modules/users/pages/protocols/slmp.adoc b/website/asciidoc/modules/users/pages/protocols/slmp.adoc index f7f83c60c39..33a981282f7 100644 --- a/website/asciidoc/modules/users/pages/protocols/slmp.adoc +++ b/website/asciidoc/modules/users/pages/protocols/slmp.adoc @@ -30,6 +30,13 @@ This initial version is *read-only* and supports reading word devices (D, W and == Connection String Options +[NOTE] +==== +Array selection uses the shared notation - a single index, an inclusive range, and +optionally the array's declared lower bound, placed before the type. See +xref:array-notation.adoc[Addressing arrays]. +==== + include::partial$slmp.adoc[] == Supported Operations diff --git a/website/asciidoc/modules/users/pages/protocols/umas.adoc b/website/asciidoc/modules/users/pages/protocols/umas.adoc index f706f755ab6..c8a03bfbcdb 100644 --- a/website/asciidoc/modules/users/pages/protocols/umas.adoc +++ b/website/asciidoc/modules/users/pages/protocols/umas.adoc @@ -30,6 +30,13 @@ between the two; where that is the case it is called out below. == Connection String Options +[NOTE] +==== +Array selection uses the shared notation - a single index, an inclusive range, and +optionally the array's declared lower bound, placed before the type. See +xref:array-notation.adoc[Addressing arrays]. +==== + include::partial$umas.adoc[] == Supported Operations