You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@plc4x.apache.org by cd...@apache.org on 2026/08/28 14:50:01 UTC
(plc4x) branch develop updated: refactor: Unified the language for defining and addressing array elements. (#2719)
This is an automated email from the ASF dual-hosted git repository.
chrisdutz pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git
The following commit(s) were added to refs/heads/develop by this push:
new c54c860073 refactor: Unified the language for defining and addressing array elements. (#2719)
c54c860073 is described below
commit c54c860073eb71bd0e3c8e1c6ec0146952f6c00a
Author: Christofer Dutz <ch...@c-ware.de>
AuthorDate: Fri Aug 28 16:49:55 2026 +0200
refactor: Unified the language for defining and addressing array elements. (#2719)
* refactor(plc4j): Unified the language for defining and addressing array elements.
* refactor(plc4go): Unified the language for defining and addressing array elements.
* refactor(plc4j): Tied up some loose ends.
* fix(plc4j): Hopefully made the PcapFilePlayer test less flaky.
* chore: Addressed the Copilot feedback.
* chore: Addressed the Copilot feedback.
* chore: Addressed the Copilot feedback.
---
RELEASE_NOTES | 166 +++++++++
.../testing/protocols/eip/DriverTestsuite.xml | 2 +-
.../protocols/modbus/tcp/DriverTestsuite.xml | 4 +-
.../modbus/tcp/DriverTestsuiteOptimized.xml | 4 +-
plc4go/internal/ads/Browser.go | 6 +
plc4go/internal/ads/Connection.go | 125 ++++++-
plc4go/internal/ads/DriverContext.go | 6 +
plc4go/internal/ads/Reader.go | 53 +--
plc4go/internal/ads/SymbolicSelection_test.go | 268 +++++++++++++++
plc4go/internal/ads/TagHandler.go | 269 ++++++---------
plc4go/internal/ads/TagHandler_test.go | 125 +++++--
plc4go/internal/ads/Writer.go | 23 +-
plc4go/internal/ads/model/Tag.go | 71 +++-
plc4go/internal/bacnetip/Reader.go | 4 +-
plc4go/internal/bacnetip/Tag.go | 45 ++-
plc4go/internal/bacnetip/TagHandler_test.go | 36 ++
plc4go/internal/cbus/Query.go | 2 +-
plc4go/internal/cbus/Query_test.go | 2 +-
plc4go/internal/cbus/Tag.go | 35 +-
plc4go/internal/cbus/TagHandler_test.go | 24 ++
plc4go/internal/cbus/Tag_test.go | 36 +-
plc4go/internal/eip/BitStringValues_test.go | 12 +-
plc4go/internal/eip/Reader.go | 21 +-
plc4go/internal/eip/Reader_test.go | 28 ++
plc4go/internal/eip/Tag.go | 67 +++-
plc4go/internal/eip/TagHandler.go | 49 ++-
plc4go/internal/eip/TagHandler_test.go | 86 ++++-
plc4go/internal/eip/UnsignedIntegerValues_test.go | 12 +-
plc4go/internal/eip/Values.go | 9 +-
plc4go/internal/eip/Values_test.go | 30 +-
plc4go/internal/eip/Writer.go | 2 +-
plc4go/internal/eip/mocks_test.go | 46 +++
plc4go/internal/firmata/Driver_test.go | 2 +-
plc4go/internal/firmata/Subscriber_test.go | 8 +-
plc4go/internal/firmata/Tag.go | 47 ++-
plc4go/internal/firmata/TagHandler.go | 57 ++--
plc4go/internal/firmata/TagHandler_test.go | 67 +++-
plc4go/internal/firmata/Writer_test.go | 6 +-
plc4go/internal/knxnetip/Browser.go | 8 +-
plc4go/internal/knxnetip/Subscriber.go | 4 +-
plc4go/internal/knxnetip/Tag.go | 31 +-
plc4go/internal/knxnetip/TagHandler.go | 64 +++-
plc4go/internal/knxnetip/TagHandler_test.go | 63 ++++
plc4go/internal/modbus/Connection_test.go | 2 +-
plc4go/internal/modbus/RegisterCodec_test.go | 2 +-
plc4go/internal/modbus/Tag.go | 169 ++++++---
plc4go/internal/modbus/TagHandler.go | 20 +-
plc4go/internal/modbus/TagHandler_test.go | 176 +++++++---
plc4go/internal/modbus/Writer_test.go | 4 +-
plc4go/internal/s7/Tag.go | 88 +++--
plc4go/internal/s7/TagHandler.go | 213 ++++++++----
plc4go/internal/s7/TagHandler_test.go | 153 ++++++++-
plc4go/internal/simulated/Driver_test.go | 2 +-
plc4go/internal/simulated/Tag.go | 27 +-
plc4go/internal/simulated/TagHandler.go | 60 +++-
plc4go/internal/simulated/TagHandler_test.go | 40 ++-
plc4go/internal/simulated/Tag_test.go | 2 +-
plc4go/internal/slmp/Connection_test.go | 22 +-
plc4go/internal/slmp/Driver_test.go | 2 +-
plc4go/internal/slmp/Subscriber_test.go | 2 +-
plc4go/internal/slmp/Tag.go | 36 +-
plc4go/internal/slmp/TagHandler.go | 38 ++-
plc4go/internal/slmp/TagHandler_test.go | 99 ++++--
plc4go/internal/slmp/endToEnd_test.go | 4 +-
plc4go/internal/umas/Browser.go | 10 +-
plc4go/pkg/api/model/mocks_test.go | 88 +++++
plc4go/pkg/api/model/plc_array_info.go | 13 +
plc4go/pom.xml | 16 +
plc4go/spi/model/AddressConstraints.go | 73 ++++
plc4go/spi/model/ArrayNotationParser.go | 286 ++++++++++++++++
plc4go/spi/model/ArrayNotationParser_test.go | 377 +++++++++++++++++++++
plc4go/spi/model/DefaultArrayInfo.go | 19 +-
plc4go/spi/model/DefaultArrayInfo_plc4xgen.go | 8 +
plc4go/spi/values/mocks_test.go | 88 +++++
.../tests/arraynotation/legacy_addresses_test.go | 124 +++++++
.../apache/plc4x/java/ads/AdsTcpConnection.java | 19 +-
.../plc4x/java/ads/resolution/TagResolver.java | 208 +++++++++++-
.../plc4x/java/ads/tag/DirectAdsStringTag.java | 41 ++-
.../apache/plc4x/java/ads/tag/DirectAdsTag.java | 93 ++++-
.../apache/plc4x/java/ads/tag/SymbolicAdsTag.java | 69 +++-
.../org/apache/plc4x/java/ads/manual/Scanner.java | 2 +-
.../plc4x/java/ads/resolution/TagResolverTest.java | 180 ++++++++++
.../plc4x/java/ads/tag/AdsArrayParityTest.java | 75 ++++
.../plc4x/java/ads/tag/AdsLegacyAddressTest.java | 58 ++++
.../plc4x/java/ads/tag/AdsSelectionOffsetTest.java | 114 +++++++
.../plc4x/java/ads/tag/DirectAdsStringTagTest.java | 7 +-
.../plc4x/java/ads/tag/DirectAdsTagBoundTest.java | 26 +-
.../plc4x/java/ads/tag/DirectAdsTagTest.java | 5 +-
.../java/ads/tag/SymbolicAdsTagRangeTest.java | 64 ++++
.../plc4x/java/eip/base/EipTcpConnection.java | 16 +-
.../org/apache/plc4x/java/eip/base/tag/EipTag.java | 170 +++++++---
.../plc4x/java/eip/base/EipArrayReadTest.java | 6 +-
.../plc4x/java/eip/base/EipBitStringTypeTest.java | 12 +-
.../plc4x/java/eip/base/EipDecodedShapeTest.java | 84 +++++
.../apache/plc4x/java/eip/base/EipDockerIT.java | 2 +-
.../java/eip/base/EipUnsignedIntegerTypeTest.java | 12 +-
.../java/eip/base/tag/EipArrayParityTest.java | 57 ++++
.../java/eip/base/tag/EipLegacyAddressTest.java | 53 +++
.../java/eip/base/tag/EipTagCoverageTest.java | 14 +-
.../plc4x/java/eip/base/tag/EipTagHandlerTest.java | 2 +-
.../plc4x/java/eip/base/tag/EipTagPathTest.java | 32 +-
.../apache/plc4x/java/eip/base/tag/EipTagTest.java | 8 +-
.../apache/plc4x/java/firmata/tag/FirmataTag.java | 46 ++-
.../plc4x/java/firmata/tag/FirmataTagAnalog.java | 25 +-
.../plc4x/java/firmata/tag/FirmataTagDigital.java | 28 +-
.../java/firmata/tag/FirmataTagPinSpanTest.java | 4 +-
.../plc4x/java/firmata/tag/FirmataTagTest.java | 10 +-
.../java/knxnetip/maual/ManualKnxNetIpBrowse.java | 4 +-
.../java/knxnetip/maual/ManualKnxNetIpRead.java | 4 +-
.../knxnetip/maual/ManualKnxNetIpSubscription.java | 2 +-
.../java/knxnetip/maual/ManualKnxNetIpWrite.java | 4 +-
.../plc4x/java/modbus/base/tag/ModbusTag.java | 139 ++++++--
.../plc4x/java/modbus/base/tag/ModbusTagCoil.java | 11 +-
.../modbus/base/tag/ModbusTagDiscreteInput.java | 11 +-
.../modbus/base/tag/ModbusTagExtendedRegister.java | 32 +-
.../modbus/base/tag/ModbusTagHoldingRegister.java | 32 +-
.../modbus/base/tag/ModbusTagInputRegister.java | 32 +-
.../plc4x/java/modbus/ModbusArrayParityTest.java | 57 ++++
.../java/modbus/ModbusBitStringArrayTest.java | 6 +-
.../apache/plc4x/java/modbus/ModbusEncodeTest.java | 30 +-
.../plc4x/java/modbus/ModbusLegacyAddressTest.java | 58 ++++
.../apache/plc4x/java/modbus/ModbusStringTest.java | 8 +-
.../apache/plc4x/java/modbus/ModbusTagTest.java | 16 +-
.../modbus/base/tag/ModbusSelectionOffsetTest.java | 138 ++++++++
.../org/apache/plc4x/java/opcua/tag/OpcuaTag.java | 85 +++--
.../opcua/manual/ManualOpcUaS71500NewFWBrowse.java | 2 +-
.../manual/ManualOpcUaS71500NewFWDriverTest.java | 2 +-
.../plc4x/java/opcua/tag/OpcuaIndexRangeTest.java | 81 +++++
.../java/openprotocol/tag/OpenProtocolTag.java | 26 +-
.../plc4x/java/openprotocol/SmallSurfaceTest.java | 13 +-
.../plc4x/java/profinet/tag/ProfinetTag.java | 75 +++-
.../profinet/ManualProfinetIoTestSimocodePN.java | 2 +-
.../profinet/ManualProfinetIoTestZylkSimocode.java | 2 +-
.../profinet/tag/ProfinetNgLegacyAddressTest.java | 51 +++
.../plc4x/java/profinet/tag/ProfinetTagTest.java | 4 +-
.../plc4x/java/profinet/tag/ProfinetTag.java | 69 +++-
.../profinet/tag/ProfinetLegacyAddressTest.java | 53 +++
.../plc4x/java/profinet/tag/ProfinetTagTest.java | 4 +-
.../plc4x/java/s7/tag/S7StringFixedLengthTag.java | 37 +-
.../plc4x/java/s7/tag/S7StringVarLengthTag.java | 24 +-
.../java/org/apache/plc4x/java/s7/tag/S7Tag.java | 146 ++++++--
.../s7/manual/ManualFactoryS71200DriverTest.java | 6 +-
.../java/s7/manual/ManualWallS7300DriverTest.java | 12 +-
.../s7/optimizer/S7BlockReadOptimizerTest.java | 2 +-
.../plc4x/java/s7/optimizer/S7OptimizerTest.java | 2 +-
.../plc4x/java/s7/tag/S7AddressStringTest.java | 104 ++++++
.../plc4x/java/s7/tag/S7ArrayParityTest.java | 75 ++++
.../plc4x/java/s7/tag/S7DeclaredBaseTest.java | 77 +++++
.../plc4x/java/s7/tag/S7LegacyAddressTest.java | 64 ++++
.../apache/plc4x/java/s7/tag/S7StringTagTest.java | 8 +-
.../java/s7/tag/S7TagElementCountBoundTest.java | 43 ++-
.../plc4x/java/s7/tag/S7TagSerializeTest.java | 6 +-
.../org/apache/plc4x/java/s7/tag/S7TagTest.java | 6 +-
.../plc4x/java/simulated/tag/SimulatedTag.java | 68 +++-
.../java/simulated/ManualSimulatedDriverTest.java | 2 +-
.../simulated/connection/SimulatedDeviceTest.java | 2 +-
.../simulated/tag/SimulatedArrayParityTest.java | 84 +++++
.../simulated/tag/SimulatedLegacyAddressTest.java | 53 +++
.../java/simulated/tag/SimulatedTagBoundTest.java | 12 +-
.../plc4x/java/simulated/tag/SimulatedTagTest.java | 8 +-
.../org/apache/plc4x/java/slmp/SlmpConnection.java | 2 +-
.../org/apache/plc4x/java/slmp/SlmpDataType.java | 22 +-
.../apache/plc4x/java/slmp/SlmpResponseMapper.java | 2 +-
.../org/apache/plc4x/java/slmp/tag/SlmpTag.java | 104 ++++--
.../plc4x/java/slmp/SlmpErrorMappingTest.java | 4 +-
.../plc4x/java/slmp/SlmpRequestBuildTest.java | 2 +-
.../plc4x/java/slmp/tag/SlmpArrayParityTest.java | 75 ++++
.../plc4x/java/slmp/tag/SlmpLegacyAddressTest.java | 58 ++++
.../java/slmp/tag/SlmpSelectionOffsetTest.java | 77 +++++
.../apache/plc4x/java/slmp/tag/SlmpTagTest.java | 23 +-
.../test/resources/slmp/slmp-driver-testsuite.xml | 2 +-
.../org/apache/plc4x/java/umas/UmasConnection.java | 21 +-
.../plc4x/java/umas/tag/SymbolicUmasTag.java | 65 +++-
.../umas/tag/SymbolicUmasTagSelectionTest.java | 74 ++++
.../java/spi/drivers/model/AddressConstraints.java | 65 ++++
.../spi/drivers/model/ArrayNotationParser.java | 301 ++++++++++++++++
.../java/spi/drivers/model/DefaultArrayInfo.java | 57 ++++
.../spi/drivers/model/ArrayNotationParserTest.java | 360 ++++++++++++++++++++
.../drivers/model/ArrayNotationRoundTripTest.java | 70 ++++
.../model/MultiDimensionConstraintTest.java | 85 +++++
.../transport/pcapreplay/PcapFilePlayerTest.java | 27 +-
.../resources/protocols/eip/DriverTestsuite.xml | 2 +-
.../protocols/modbus/tcp/DriverTestsuite.xml | 4 +-
.../modbus/tcp/DriverTestsuiteOptimized.xml | 4 +-
website/asciidoc/modules/users/nav.adoc | 1 +
.../modules/users/pages/array-notation.adoc | 199 +++++++++++
.../modules/users/pages/protocols/ads.adoc | 7 +
.../modules/users/pages/protocols/eip.adoc | 22 +-
.../modules/users/pages/protocols/firmata.adoc | 12 +-
.../modules/users/pages/protocols/modbus.adoc | 11 +-
.../modules/users/pages/protocols/opcua.adoc | 7 +
.../modules/users/pages/protocols/profinet.adoc | 7 +
.../asciidoc/modules/users/pages/protocols/s7.adoc | 9 +-
.../modules/users/pages/protocols/simulated.adoc | 7 +
.../modules/users/pages/protocols/slmp.adoc | 7 +
.../modules/users/pages/protocols/umas.adoc | 7 +
196 files changed, 8302 insertions(+), 1224 deletions(-)
diff --git a/RELEASE_NOTES b/RELEASE_NOTES
index 99964060e8..1d6a47fa22 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 e69722f371..25169cba24 100644
--- a/plc4go/assets/testing/protocols/eip/DriverTestsuite.xml
+++ b/plc4go/assets/testing/protocols/eip/DriverTestsuite.xml
@@ -1103,7 +1103,7 @@
<tags isList="true">
<tag className="org.apache.plc4x.test.driver.internal.api.TestTag">
<name>hurz</name>
- <address>%rate:DINT:4</address>
+ <address>%rate[0..3]:DINT</address>
</tag>
</tags>
</TestReadRequest>
diff --git a/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuite.xml b/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuite.xml
index bca1b8f01d..cc6976df48 100644
--- a/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuite.xml
+++ b/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuite.xml
@@ -130,7 +130,7 @@
<tags isList="true">
<tag className="org.apache.plc4x.test.driver.internal.api.TestTag">
<name>hurz</name>
- <address>holding-register:1:REAL[2]</address>
+ <address>holding-register:1[0..1]:REAL</address>
</tag>
</tags>
</TestReadRequest>
@@ -482,7 +482,7 @@
<tags isList="true">
<tag className="org.apache.plc4x.test.driver.internal.api.TestValueTag">
<name>hurz</name>
- <address>holding-register:1:REAL[2]</address>
+ <address>holding-register:1[0..1]:REAL</address>
<value>3.1415927</value>
<value>3.1415927</value>
</tag>
diff --git a/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuiteOptimized.xml b/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuiteOptimized.xml
index 9be6878506..f5c36fc98e 100644
--- a/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuiteOptimized.xml
+++ b/plc4go/assets/testing/protocols/modbus/tcp/DriverTestsuiteOptimized.xml
@@ -134,7 +134,7 @@
<tags isList="true">
<tag className="org.apache.plc4x.test.driver.internal.api.TestTag">
<name>hurz</name>
- <address>holding-register:1:REAL[2]</address>
+ <address>holding-register:1[0..1]:REAL</address>
</tag>
</tags>
</TestReadRequest>
@@ -452,7 +452,7 @@
<tags isList="true">
<tag className="org.apache.plc4x.test.driver.internal.api.TestValueTag">
<name>hurz</name>
- <address>holding-register:1:REAL[2]</address>
+ <address>holding-register:1[0..1]:REAL</address>
<value>3.1415927</value>
<value>3.1415927</value>
</tag>
diff --git a/plc4go/internal/ads/Browser.go b/plc4go/internal/ads/Browser.go
index 808b0328e0..a60d2760dd 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 9bf6c9e79a..8f42882833 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 4b81eea754..d4a6ff94bc 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 858764f455..ba71355e88 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 0000000000..26bd880eab
--- /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 8ef482b531..cd21a736e1 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<indexGroupHex>[0-9a-fA-F]+))|(?P<indexGroup>\d+))/((0[xX](?P<indexOffsetHex>[0-9a-fA-F]+))|(?P<indexOffset>\d+)):(?P<adsDataType>STRING|WSTRING)\((?P<stringLength>\d{1,3})\)(?P<arrayInfo>((\[(\d+)])|(\[(\d+)\.\.(\d+)])|(\[(\d+):(\d+)]))*)`),
- directAdsTag: regexp.MustCompile(`^((0[xX](?P<indexGroupHex>[0-9a-fA-F]+))|(?P<indexGroup>\d+))/((0[xX](?P<indexOffsetHex>[0-9a-fA-F]+))|(?P<indexOffset>\d+)):(?P<adsDataType>\w+)(?P<arrayInfo>((\[(\d+)])|(\[(\d+)\.\.(\d+)])|(\[(\d+):(\d+)]))*)`),
- symbolicAdsTag: regexp.MustCompile(`^(?P<symbolicAddress>[^\[]+)(?P<arrayInfo>((\[(\d+)])|(\[(\d+)\.\.(\d+)])|(\[(\d+):(\d+)]))*)`),
- arrayInfoSegment: regexp.MustCompile(`((^(?P<numElements>\d+)$)|(^((?P<startElement>\d+)\.\.(?P<endElement>\d+))$)|(^((?P<startElement2>\d+):(?P<numElements2>\d+)))$)`),
+ directAdsStringTag: regexp.MustCompile(`^((0[xX](?P<indexGroupHex>[0-9a-fA-F]+))|(?P<indexGroup>\d+))/((0[xX](?P<indexOffsetHex>[0-9a-fA-F]+))|(?P<indexOffset>\d+))` + spiModel.ArrayGroupPattern + `:(?P<adsDataType>STRING|WSTRING)\((?P<stringLength>\d{1,3})\)$`),
+ directAdsTag: regexp.MustCompile(`^((0[xX](?P<indexGroupHex>[0-9a-fA-F]+))|(?P<indexGroup>\d+))/((0[xX](?P<indexOffsetHex>[0-9a-fA-F]+))|(?P<indexOffset>\d+))` + spiModel.ArrayGroupPattern + `:(?P<adsDataType>\w+)$`),
+ symbolicAdsTag: regexp.MustCompile(`^(?P<symbolicAddress>[^\[]+)` + 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<indexGroupHex>[0-9a-fA-F]+))|(?P<indexGroup>\d+))/((0[xX](?P<indexOffsetHex>[0-9a-fA-F]+))|(?P<indexOffset>\d+)):(?P<adsDataType>STRING|WSTRING)\((?P<stringLength>\d{1,3})\)(?P<arrayInfo>((\[(\d+)])|(\[(\d+)\.\.(\d+)])|(\[(\d+):(\d+)]))*)`),
- directAdsTag: regexp.MustCompile(`^((0[xX](?P<indexGroupHex>[0-9a-fA-F]+))|(?P<indexGroup>\d+))/((0[xX](?P<indexOffsetHex>[0-9a-fA-F]+))|(?P<indexOffset>\d+)):(?P<adsDataType>\w+)(?P<arrayInfo>((\[(\d+)])|(\[(\d+)\.\.(\d+)])|(\[(\d+):(\d+)]))*)`),
- symbolicAdsTag: regexp.MustCompile(`^(?P<symbolicAddress>[^\[]+)(?P<arrayInfo>((\[(\d+)])|(\[(\d+)\.\.(\d+)])|(\[(\d+):(\d+)]))*)`),
- arrayInfoSegment: regexp.MustCompile(`((^(?P<numElements>\d+)$)|(^((?P<startElement>\d+)\.\.(?P<endElement>\d+))$)|(^((?P<startElement2>\d+):(?P<numElements2>\d+)))$)`),
+ directAdsStringTag: regexp.MustCompile(`^((0[xX](?P<indexGroupHex>[0-9a-fA-F]+))|(?P<indexGroup>\d+))/((0[xX](?P<indexOffsetHex>[0-9a-fA-F]+))|(?P<indexOffset>\d+))` + spiModel.ArrayGroupPattern + `:(?P<adsDataType>STRING|WSTRING)\((?P<stringLength>\d{1,3})\)$`),
+ directAdsTag: regexp.MustCompile(`^((0[xX](?P<indexGroupHex>[0-9a-fA-F]+))|(?P<indexGroup>\d+))/((0[xX](?P<indexOffsetHex>[0-9a-fA-F]+))|(?P<indexOffset>\d+))` + spiModel.ArrayGroupPattern + `:(?P<adsDataType>\w+)$`),
+ symbolicAdsTag: regexp.MustCompile(`^(?P<symbolicAddress>[^\[]+)` + 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 b081bff771..aa9fb9b442 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 e9966ac4b1..390f9f1f66 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 0d8d7f3d7d..61617c7e6f 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 bcc434db8b..467478ae9b 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 e4f1666cdd..53ac7921ef 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 8bf1effb17..2f24dcda36 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 31539e3a10..c069daed48 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 601fbc4285..2409199d5b 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 e429ba2f57..a1a90e548a 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 03b52d8bc3..886ab98b22 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 d54244269b..80f48ae9ed 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 b08614719f..bc4baadf5a 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 977e37d70e..0aa984b4c8 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 0ee2ca17fe..b3d62da2b2 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 0289acad4c..64c273ef6c 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 9b5bb999d9..5358a6be5e 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<tag>[%a-zA-Z_.0-9]+\[?[0-9]*]?):?(?P<dataType>[A-Z]*):?(?P<elementNb>[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<tag>[%a-zA-Z_.0-9]+)` + spiModel.ArrayGroupPattern + `(?::(?P<dataType>[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 8b711491e7..b64c685f4b 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 db85887ee2..191d20e6bd 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 29f63c44a2..794c6b13fd 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 ba20e1fa92..879e3aedbb 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 acbdbe46a7..de6a485641 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 9e68d70f11..9925eea8d5 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 93dd5a7ca4..967edc4262 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 4396a075a7..63dd67d08c 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 0c1ea3090e..05a86ebd19 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 7648667dc9..1afa61eaf9 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<address>\d+)(\[(?P<quantity>\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<address>\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 3a5e5a8cde..bb1276b2b6 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 7f0e85084d..038868c601 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 5e20effcdc..4c31f921ef 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 0f0a305d10..7ee2e01602 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 3c942f10fc..df440b6904 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 c3fa56b2c1..64a8687f87 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<mainGroup>(\d{1,2}|\*|\[(\d{1,2}|\d{1,2}\-\d{1,2})(,(\d{1,2}|\d{1,2}\-\d{1,2}))*]))/(?P<subGroup>(\d{1,4}|\*|\[(\d{1,4}|\d{1,4}\-\d{1,4})(,(\d{1,4}|\d{1,4}\-\d{1,4}))*]))(:(?P<datatype>[a-zA-Z_]+))?$`),
groupAddress1Level: regexp.MustCompile(`^(?P<mainGroup>(\d{1,5}|\*|\[(\d{1,5}|\d{1,5}\-\d{1,5})(,(\d{1,5}|\d{1,5}\-\d{1,5}))*]))(:(?P<datatype>[a-zA-Z_]+))?$`),
- deviceQuery: regexp.MustCompile(`^(?P<mainGroup>(\d{1,2}|\*|\[(\d{1,2}|\d{1,2}\-\d{1,2})(,(\d{1,2}|\d{1,2}\-\d{1,2}))*]))\.(?P<middleGroup>(\d{1,2}|\*|\[(\d{1,2}|\d{1,2}\-\d{1,2})(,(\d{1,2}|\d{1,2}\-\d{1,2}))*]))\.(?P<subGroup>(\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<mainGroup>\d{1,2})\.(?P<middleGroup>\d)\.(?P<subGroup>\d{1,3})#(?P<objectId>\d{1,3})\/(?P<propertyId>\d{1,3})(\/(?P<propertyIndex>\d{1,4}))?(\[(?P<numElements>\d{1,2})])?$`),
- deviceMemoryAddress: regexp.MustCompile(`^(?P<mainGroup>\d{1,2})\.(?P<middleGroup>\d)\.(?P<subGroup>\d{1,3})#(?P<address>[0-9a-fA-F]{1,8})(:(?P<datatype>[a-zA-Z_]+)(\[(?P<numElements>\d+)])?)?$`),
+ deviceQuery: regexp.MustCompile(`^(?P<mainGroup>(\d{1,2}|\*|\[(\d{1,2}|\d{1,2}\-\d{1,2})(,(\d{1,2}|\d{1,2}\-\d{1,2}))*]))\.(?P<middleGroup>(\d{1,2}|\*|\[(\d{1,2}|\d{1,2}\-\d{1,2})(,(\d{1,2}|\d{1,2}\-\d{1,2}))*]))\.(?P<subGroup>(\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<mainGroup>\d{1,2})\.(?P<middleGroup>\d)\.(?P<subGroup>\d{1,3})#(?P<objectId>\d{1,3})\/(?P<propertyId>\d{1,3})(\/(?P<propertyIndex>\d{1,4}))?` + spiModel.ArrayGroupPattern + `$`),
+ deviceMemoryAddress: regexp.MustCompile(`^(?P<mainGroup>\d{1,2})\.(?P<middleGroup>\d)\.(?P<subGroup>\d{1,3})#(?P<address>[0-9a-fA-F]{1,8})` + spiModel.ArrayGroupPattern + `(:(?P<datatype>[a-zA-Z_]+))?$`),
deviceCommunicationObjectQuery: regexp.MustCompile(`^(?P<mainGroup>\d{1,2})\.(?P<middleGroup>\d)\.(?P<subGroup>\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 c4fc933338..9fa8a22b97 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 2804250c98..732f268591 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 1382d1397b..f5bbcf5d69 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 ea988ec8c8..596e57bf17 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 f4f372eeeb..af244deaec 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<address>\d+)(:(?P<datatype>[a-zA-Z_]+)(\((?P<stringLength>\d+)\))?)?(\[(?P<quantity>\d+)])?` + tagConfigPattern + `$`
- generalFixedDigitAddressPattern := `(?P<address>\d{4,5})?(:(?P<datatype>[a-zA-Z_]+)(\((?P<stringLength>\d+)\))?)?(\[(?P<quantity>\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<address>\d+)` + spiModel.ArrayGroupPattern + `(:(?P<datatype>[a-zA-Z_]+)(\((?P<stringLength>\d+)\))?)?` + tagConfigPattern + `$`
+ generalFixedDigitAddressPattern := `(?P<address>\d{4,5})?` + spiModel.ArrayGroupPattern + `(:(?P<datatype>[a-zA-Z_]+)(\((?P<stringLength>\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 4960a33d77..05a2d79641 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 6cebce5a86..337d305629 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 cdc5044576..c0441877d3 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 e2fca04d5b..4956fbb1eb 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<memoryArea>.)(?P<transferSizeCode>[XBWD]?)(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?:(?P<dataType>(?:S5)?[a-zA-Z_]+)(\[(?P<numElements>\d+)])?`),
+ addressPattern: regexp.MustCompile(`^%(?P<memoryArea>.)(?P<transferSizeCode>[XBWD]?)(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?` + spiModel.ArrayGroupPattern + `:(?P<dataType>(?:S5)?[a-zA-Z_]+)$`),
//blockNumber usually has its max hat around 64000 --> 5digits
- dataBlockAddressPattern: regexp.MustCompile(`^%DB(?P<blockNumber>\d{1,5}).DB(?P<transferSizeCode>[XBWD]?)(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?:(?P<dataType>(?:S5)?[a-zA-Z_]+)(\[(?P<numElements>\d+)])?`),
- dataBlockShortPattern: regexp.MustCompile(`^%DB(?P<blockNumber>\d{1,5}):(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?:(?P<dataType>(?:S5)?[a-zA-Z_]+)(\[(?P<numElements>\d+)])?`),
- dataBlockStringAddressPattern: regexp.MustCompile(`^%DB(?P<blockNumber>\d{1,5}).DB(?P<transferSizeCode>[XBWD]?)(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?:(?P<dataType>STRING|WSTRING)\((?P<stringLength>\d{1,3})\)(\[(?P<numElements>\d+)])?`),
- dataBlockStringShortPattern: regexp.MustCompile(`^%DB(?P<blockNumber>\d{1,5}):(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?:(?P<dataType>STRING|WSTRING)\((?P<stringLength>\d{1,3})\)(\[(?P<numElements>\d+)])?`),
- dataBlockStringVarLengthAddressPattern: regexp.MustCompile(`^%DB(?P<blockNumber>\d{1,5}).DB(?P<transferSizeCode>[XBWD]?)(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?:(?P<dataType>STRING|WSTRING)(\[(?P<numElements>\d+)])?$`),
- dataBlockStringVarLengthShortPattern: regexp.MustCompile(`^%DB(?P<blockNumber>\d{1,5}):(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?:(?P<dataType>STRING|WSTRING)(\[(?P<numElements>\d+)])?$`),
+ dataBlockAddressPattern: regexp.MustCompile(`^%DB(?P<blockNumber>\d{1,5}).DB(?P<transferSizeCode>[XBWD]?)(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?` + spiModel.ArrayGroupPattern + `:(?P<dataType>(?:S5)?[a-zA-Z_]+)$`),
+ dataBlockShortPattern: regexp.MustCompile(`^%DB(?P<blockNumber>\d{1,5}):(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?` + spiModel.ArrayGroupPattern + `:(?P<dataType>(?:S5)?[a-zA-Z_]+)$`),
+ dataBlockStringAddressPattern: regexp.MustCompile(`^%DB(?P<blockNumber>\d{1,5}).DB(?P<transferSizeCode>[XBWD]?)(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?` + spiModel.ArrayGroupPattern + `:(?P<dataType>STRING|WSTRING)\((?P<stringLength>\d{1,3})\)$`),
+ dataBlockStringShortPattern: regexp.MustCompile(`^%DB(?P<blockNumber>\d{1,5}):(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?` + spiModel.ArrayGroupPattern + `:(?P<dataType>STRING|WSTRING)\((?P<stringLength>\d{1,3})\)$`),
+ dataBlockStringVarLengthAddressPattern: regexp.MustCompile(`^%DB(?P<blockNumber>\d{1,5}).DB(?P<transferSizeCode>[XBWD]?)(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?` + spiModel.ArrayGroupPattern + `:(?P<dataType>STRING|WSTRING)$`),
+ dataBlockStringVarLengthShortPattern: regexp.MustCompile(`^%DB(?P<blockNumber>\d{1,5}):(?P<byteOffset>\d{1,7})(.(?P<bitOffset>[0-7]))?` + spiModel.ArrayGroupPattern + `:(?P<dataType>STRING|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 3cfc2d7385..095dc28d26 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 22904d6c0d..5aa7825460 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 b0cfe7c34e..595554c38a 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 e864e16b42..9121ce8acd 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<type>\w+)/(?P<name>[a-zA-Z0-9_\\.]+):(?P<dataType>[a-zA-Z0-9]+)(\[(?P<numElements>\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<type>\w+)/(?P<name>[a-zA-Z0-9_\\.]+)` + spiModel.ArrayGroupPattern + `:(?P<dataType>[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 eab019a184..00dd989889 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 3db5b0a834..04a2e82332 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 d74598e7b1..401b417ddf 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 4a3d3b243b..b6c88dab6e 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 f1ccd91810..540201a5e6 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 db5adf727f..2af29979ec 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 2f5088ce03..a9e8b305f2 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<device>[A-Za-z]+?)(?P<hexPrefix>0[xX])?(?P<address>[0-9A-Fa-f]+)(:(?P<datatype>[A-Za-z_]+))?(\[(?P<quantity>\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<device>[A-Za-z]+?)(?P<hexPrefix>0[xX])?(?P<address>[0-9A-Fa-f]+)` + spiModel.ArrayGroupPattern + `(:(?P<datatype>[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 e53f2bbe3c..bb16d29d2f 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 4cef6adb32..b0167e8cff 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 d19937d136..73cfb2716a 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 35aa25d243..4476cdf14c 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 be74ceaf0a..ffdcc9ee38 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 212524c814..6d4ab1d514 100644
--- a/plc4go/pom.xml
+++ b/plc4go/pom.xml
@@ -519,6 +519,22 @@
<!-- Scope is 'provided' as this way it's not shipped with the driver -->
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.apache.plc4x</groupId>
+ <artifactId>plc4x-protocols-slmp</artifactId>
+ <version>1.0.0-SNAPSHOT</version>
+ <type>test-jar</type>
+ <!-- Scope is 'provided' as this way it's not shipped with the driver -->
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.plc4x</groupId>
+ <artifactId>plc4x-protocols-iec-60870</artifactId>
+ <version>1.0.0-SNAPSHOT</version>
+ <type>test-jar</type>
+ <!-- Scope is 'provided' as this way it's not shipped with the driver -->
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>org.apache.plc4x</groupId>
<artifactId>plc4x-protocols-simulated</artifactId>
diff --git a/plc4go/spi/model/AddressConstraints.go b/plc4go/spi/model/AddressConstraints.go
new file mode 100644
index 0000000000..f8c83ca2e5
--- /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 0000000000..97bf5f844f
--- /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<array>` + 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 0000000000..5e03f60b61
--- /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 4e074f228c..ba6c13066f 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 3210bddcc3..cfcd0651ac 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 4718687783..5748792c0a 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 0000000000..cc5aa29716
--- /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/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 56540bd7ad..9b874c8ec6 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.AmsNetId;
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.PlcInvalidTagException;
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.net.InetAddress;
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 @@ public class AdsTcpConnection extends ConnectionBase<AdsConfiguration> {
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 @@ public class AdsTcpConnection extends ConnectionBase<AdsConfiguration> {
List<ArrayInfo> arrayInfo = new ArrayList<>(dataType.getArrayInfo().size());
List<ArrayInfo> 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 @@ public class AdsTcpConnection extends ConnectionBase<AdsConfiguration> {
List<ArrayInfo> arrayInfo = new ArrayList<>(childDataType.getArrayInfo().size());
List<ArrayInfo> 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 2827d0a4b6..2039e146d0 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.AdsDataTypeArrayInfo;
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 final class TagResolver {
+ " 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<ArrayInfo> 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 final class TagResolver {
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<ArrayInfo> selection) {
+ if (selection.isEmpty()) {
+ return part;
+ }
+ if (part.child() != null) {
+ return new AddressParser.AddressPart(part.baseSegment(), part.arrayIndices(),
+ withSelectionStart(part.child(), selection));
+ }
+ List<Integer> 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.
+ *
+ * <p>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.
+ *
+ * <p>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.</p>
+ */
+ public static ResolvedAdsTag withDirectSelection(ResolvedAdsTag resolved, List<ArrayInfo> selection) {
+ if (selection.isEmpty()) {
+ return resolved;
+ }
+ List<AdsDataTypeArrayInfo> 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.
+ *
+ * <p>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.</p>
+ */
+ private static ResolvedAdsTag scaleToSelection(ResolvedAdsTag resolved, List<ArrayInfo> 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<AdsDataTypeArrayInfo> 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.
+ *
+ * <p>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.</p>
+ *
+ * <p>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.</p>
+ */
+ private static void verifySelectionIsOneRead(List<ArrayInfo> selection,
+ List<AdsDataTypeArrayInfo> 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<Integer> arrayIndices,
- AddressParser.AddressPart child) {
+ AddressParser.AddressPart child,
+ List<ArrayInfo> 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<Integer> arrayIndices,
- AddressParser.AddressPart child) {
+ AddressParser.AddressPart child,
+ List<ArrayInfo> 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 @@ public final class TagResolver {
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 @@ public final class TagResolver {
private ResolvedAdsTag resolveChild(long indexGroup, long indexOffset,
AdsDataTypeTableEntry dataType,
- AddressParser.AddressPart child) {
+ AddressParser.AddressPart child,
+ List<ArrayInfo> selection) {
AdsDataTypeTableEntry fieldEntry = null;
for (AdsDataTypeTableEntry c : dataType.getChildren()) {
if (c.getMainName().equals(child.baseSegment())) {
@@ -184,7 +372,7 @@ public final class TagResolver {
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 557fcd4b30..3315f7a573 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](?<indexGroupHex>[0-9a-fA-F]{1,8}))|(?<indexGroup>\\d{1,10}))" +
"/((0[xX](?<indexOffsetHex>[0-9a-fA-F]{1,8}))|(?<indexOffset>\\d{1,10}))" +
- ":(?<adsDataType>STRING|WSTRING)\\((?<stringLength>\\d{1,3})\\)" +
- "(\\[(?<numberOfElements>\\d{1,10})])?");
+ ArrayNotationParser.ARRAY_GROUP +
+ ":(?<adsDataType>STRING|WSTRING)\\((?<stringLength>\\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 class DirectAdsStringTag extends DirectAdsTag implements AdsStringTag {
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 class DirectAdsStringTag extends DirectAdsTag implements AdsStringTag {
@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 ef2c973c22..2e33ca33b2 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](?<indexGroupHex>[0-9a-fA-F]{1,8}))|(?<indexGroup>\\d{1,10}))" +
"/((0[xX](?<indexOffsetHex>[0-9a-fA-F]{1,8}))|(?<indexOffset>\\d{1,10}))" +
- ":(?<adsDataType>\\w+)(\\[(?<numberOfElements>\\d{1,10})])?");
+ ArrayNotationParser.ARRAY_GROUP +
+ ":(?<adsDataType>\\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 class DirectAdsTag implements AdsTag {
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 class DirectAdsTag implements AdsTag {
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.
+ *
+ * <p>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.</p>
+ */
+ 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 class DirectAdsTag implements AdsTag {
@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 class DirectAdsTag implements AdsTag {
@Override
public List<ArrayInfo> 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 class DirectAdsTag implements AdsTag {
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 687ae385cf..441c11a10a 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 @@ package org.apache.plc4x.java.ads.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.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.
+ *
+ * <p>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 class SymbolicAdsTag implements AdsTag {
}
public static SymbolicAdsTag of(String address) {
- Matcher matcher = SYMBOLIC_ADDRESS_PATTERN.matcher(address);
- if (!matcher.matches()) {
+ // 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 path = ArrayNotationParser.addressPart(address);
+ if (!SYMBOLIC_ADDRESS_PATTERN.matcher(path).matches()) {
throw new PlcInvalidTagException(address, SYMBOLIC_ADDRESS_PATTERN, "{address}");
}
- return new SymbolicAdsTag(address, null, Collections.emptyList());
+ String expression = ArrayNotationParser.expressionPart(address);
+ List<ArrayInfo> 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.
+ *
+ * <p>Distinct from {@link #getArrayInfo()}, which describes the shape of the value the caller
+ * receives.
+ */
+ public List<ArrayInfo> 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<ArrayInfo> dimensions = ArrayNotationParser.parse(expression, symbolicAddress);
+ return dimensions.get(dimensions.size() - 1).getBase();
}
public String getSymbolicAddress() {
@@ -78,8 +129,18 @@ public class SymbolicAdsTag implements AdsTag {
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<ArrayInfo> 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 bfc4fd8a44..8e1bbd23e6 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 class Scanner {
// 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 9254e15131..0f5673eb3d 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 @@ package org.apache.plc4x.java.ads.resolution;
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 @@ class TagResolverTest {
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 @@ class TagResolverTest {
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 @@ class TagResolverTest {
() -> 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 @@ class TagResolverTest {
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 0000000000..3390e6e67d
--- /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).
+ *
+ * <p>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<ArrayInfo> 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<ArrayInfo> 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 0000000000..1eca9a2480
--- /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.
+ *
+ * <p>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 0000000000..9e27ce424f
--- /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 <em>byte</em> 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 ceedf0ecaa..04403bacdb 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 @@ class DirectAdsStringTagTest {
@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 @@ class DirectAdsStringTagTest {
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 85f06b9971..717cf8f028 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 @@ public class DirectAdsTagBoundTest {
@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 @@ public class DirectAdsTagBoundTest {
}
@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 @@ public class DirectAdsTagBoundTest {
@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 @@ public class DirectAdsTagBoundTest {
@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 @@ public class DirectAdsTagBoundTest {
}
@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 fed7824815..e3adbc7286 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 @@ class DirectAdsTagTest {
@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 @@ class DirectAdsTagTest {
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 0000000000..ee8ca9f1ab
--- /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.
+ *
+ * <p>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 189543d962..278d92e1eb 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 @@ public class EipTcpConnection extends PollingSubscriptionConnectionBase<EIPConfi
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 @@ public class EipTcpConnection extends PollingSubscriptionConnectionBase<EIPConfi
List<String> 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 @@ public class EipTcpConnection extends PollingSubscriptionConnectionBase<EIPConfi
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 @@ public class EipTcpConnection extends PollingSubscriptionConnectionBase<EIPConfi
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 fb9634b6c0..ae7814c389 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 @@ package org.apache.plc4x.java.eip.base.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.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 @@ import java.util.regex.Pattern;
public class EipTag implements PlcTag, Serializable {
- private static final Pattern ADDRESS_PATTERN =
- Pattern.compile("^(?<tag>[%a-zA-Z_.0-9]+\\[?[0-9]*]?):?(?<dataType>[A-Z]*):?(?<elementNb>[0-9]*)");
+ private static final Pattern ADDRESS_PATTERN = Pattern.compile(
+ "^(?<tag>[%a-zA-Z_.0-9]+)"
+ + ArrayNotationParser.ARRAY_GROUP
+ + "(?::(?<dataType>[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 class EipTag implements PlcTag, Serializable {
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<ArrayInfo> selection;
+ private final boolean scalarSelection;
private final List<PathElement> pathElements;
public EipTag(String tag) {
@@ -82,34 +100,84 @@ public class EipTag implements PlcTag, Serializable {
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<ArrayInfo> 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<ArrayInfo> 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<PathElement> 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.
+ *
+ * <p>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<PathElement> decomposePath(String tag, List<ArrayInfo> arrayInfo) {
if (tag == null) {
return Collections.emptyList();
}
- Matcher matcher = PATH_PATTERN.matcher(tag);
+ Matcher matcher = PATH_PATTERN.matcher(ArrayNotationParser.addressPart(tag));
List<PathElement> 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 @@ public class EipTag implements PlcTag, Serializable {
@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 class EipTag implements PlcTag, Serializable {
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.
+ *
+ * <p>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<ArrayInfo> 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 class EipTag implements PlcTag, Serializable {
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> 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 class EipTag implements PlcTag, Serializable {
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 beaab91101..ad128a8035 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 @@ class EipArrayReadTest {
*/
@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 @@ class EipArrayReadTest {
@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 9fd917e8d4..d593b9011d 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 @@ class EipBitStringTypeTest {
@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 @@ class EipBitStringTypeTest {
@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 @@ class EipBitStringTypeTest {
@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 @@ class EipBitStringTypeTest {
@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 0000000000..bb1adb3962
--- /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.
+ *
+ * <p>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.</p>
+ */
+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 02af9f0366..cf19d64f2a 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 @@ public class EipDockerIT {
// 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 70835d10ed..7c88cad641 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 @@ class EipUnsignedIntegerTypeTest {
@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 @@ class EipUnsignedIntegerTypeTest {
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 @@ class EipUnsignedIntegerTypeTest {
@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 @@ class EipUnsignedIntegerTypeTest {
@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 0000000000..89c0a4b251
--- /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).
+ *
+ * <p>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<ArrayInfo> 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 0000000000..b07e2bd21c
--- /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.
+ *
+ * <p>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 3f432b12a4..15a7aace1d 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 @@ class EipTagCoverageTest {
@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 @@ class EipTagCoverageTest {
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 @@ class EipTagCoverageTest {
@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 @@ class EipTagCoverageTest {
// 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 41183f56da..7e32c8b6f4 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 8663e50e57..0db1c27bee 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 @@ class EipTagPathTest {
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 @@ class EipTagPathTest {
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 @@ class EipTagPathTest {
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<PathElement> 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 982d23de14..2159d080e6 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 class EipTagTest {
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 d81e26ebb8..5b619725d5 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 @@ import java.util.regex.Pattern;
public abstract class FirmataTag implements PlcTag {
public static final Pattern ADDRESS_PATTERN =
- Pattern.compile("(?<address>\\d{1,3})(\\[(?<quantity>\\d{1,3})])?");
+ Pattern.compile("(?<address>\\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 abstract class FirmataTag implements PlcTag {
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 abstract class FirmataTag implements PlcTag {
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 abstract class FirmataTag implements PlcTag {
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 964a37b5cb..dc15a61282 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 @@ 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.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 class FirmataTagAnalog extends FirmataTag {
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 class FirmataTagAnalog extends FirmataTag {
@Override
public List<ArrayInfo> 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 class FirmataTagAnalog extends FirmataTag {
}
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 6cb94fc2c0..dc03422a2f 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.exceptions.PlcInvalidTagException;
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 class FirmataTagDigital extends FirmataTag {
@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 class FirmataTagDigital extends FirmataTag {
@Override
public List<ArrayInfo> 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 class FirmataTagDigital extends FirmataTag {
}
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 3e4764e06b..70af3355b7 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 @@ public class FirmataTagPinSpanTest {
@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 b15b15ad98..4cf2d103b8 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 @@ class FirmataTagTest {
@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 @@ class FirmataTagTest {
@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 @@ class FirmataTagTest {
@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 a36c6f6fb3..9fde14f810 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 b599f406da..6c921192b1 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 c558d74d5b..8897cbb18f 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 9fd992dee7..7764e7c67c 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 61a3438ed2..b22a56437f 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.Serializable;
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("(?<address>\\d{1,9})(:(?<datatype>[a-zA-Z_]+)(\\((?<stringLength>\\d{1,5})\\))?)?(\\[(?<quantity>\\d{1,5})])?");
- public static final Pattern FIXED_DIGIT_MODBUS_PATTERN = Pattern.compile("(?<address>\\d{4,5})?(:(?<datatype>[a-zA-Z_]+)(\\((?<stringLength>\\d{1,5})\\))?)?(\\[(?<quantity>\\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("(?<address>\\d{1,9})" + ARRAY_EXPRESSION + "(:(?<datatype>[a-zA-Z_]+)(\\((?<stringLength>\\d{1,5})\\))?)?");
+ public static final Pattern FIXED_DIGIT_MODBUS_PATTERN = Pattern.compile("(?<address>\\d{4,5})?" + ARRAY_EXPRESSION + "(:(?<datatype>[a-zA-Z_]+)(\\((?<stringLength>\\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.
+ *
+ * <p>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.</p>
+ *
+ * @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.
+ *
+ * <p>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.</p>
+ *
+ * <p>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.</p>
+ */
+ 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.
+ *
+ * <p>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.</p>
+ */
+ 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 abstract class ModbusTag implements PlcTag, Serializable {
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 @@ public abstract class ModbusTag implements PlcTag, Serializable {
protected ModbusTag(int address, Integer quantity, Integer stringLength, ModbusDataType dataType,
Map<String, String> config) {
+ this(address, quantity, stringLength, dataType, config, (quantity != null) && (quantity > 1));
+ }
+
+ protected ModbusTag(int address, Integer quantity, Integer stringLength, ModbusDataType dataType,
+ Map<String, String> 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 abstract class ModbusTag implements PlcTag, Serializable {
}
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 abstract class ModbusTag implements PlcTag, Serializable {
@Override
public List<ArrayInfo> 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 4eed3baed7..bfe3068414 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 class ModbusTagCoil extends ModbusTag {
super(address, quantity, stringLength, dataType, config);
}
+ public ModbusTagCoil(int address, Integer quantity, Integer stringLength, ModbusDataType dataType, Map<String, String> config, boolean explicitRange) {
+ super(address, quantity, stringLength, dataType, config, explicitRange);
+ }
+
protected String getAddressStringPrefix() {
return ADDRESS_PREFIX;
}
@@ -82,8 +86,9 @@ public class ModbusTagCoil extends ModbusTag {
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 class ModbusTagCoil extends ModbusTag {
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 c40a716aaf..f4acdd5f5c 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 class ModbusTagDiscreteInput extends ModbusTag {
super(address, quantity, stringLength, dataType, config);
}
+ public ModbusTagDiscreteInput(int address, Integer quantity, Integer stringLength, ModbusDataType dataType, Map<String, String> config, boolean explicitRange) {
+ super(address, quantity, stringLength, dataType, config, explicitRange);
+ }
+
protected String getAddressStringPrefix() {
return ADDRESS_PREFIX;
}
@@ -82,8 +86,9 @@ public class ModbusTagDiscreteInput extends ModbusTag {
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 class ModbusTagDiscreteInput extends ModbusTag {
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 26390f4a1b..136edb2d15 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 class ModbusTagExtendedRegister extends ModbusTag {
super(address, quantity, stringLength, dataType, config);
}
+ public ModbusTagExtendedRegister(int address, Integer quantity, Integer stringLength, ModbusDataType dataType, Map<String, String> config, boolean explicitRange) {
+ super(address, quantity, stringLength, dataType, config, explicitRange);
+ }
+
protected String getAddressStringPrefix() {
return ADDRESS_PREFIX;
}
@@ -83,21 +87,27 @@ public class ModbusTagExtendedRegister extends ModbusTag {
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 9560717529..161540511e 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 class ModbusTagHoldingRegister extends ModbusTag {
super(address, quantity, stringLength, dataType, config);
}
+ public ModbusTagHoldingRegister(int address, Integer quantity, Integer stringLength, ModbusDataType dataType, Map<String, String> config, boolean explicitRange) {
+ super(address, quantity, stringLength, dataType, config, explicitRange);
+ }
+
protected String getAddressStringPrefix() {
return ADDRESS_PREFIX;
}
@@ -81,22 +85,28 @@ public class ModbusTagHoldingRegister extends ModbusTag {
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 bb110fc1d2..284cd7d031 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 class ModbusTagInputRegister extends ModbusTag {
super(address, quantity, stringLength, dataType, config);
}
+ public ModbusTagInputRegister(int address, Integer quantity, Integer stringLength, ModbusDataType dataType, Map<String, String> config, boolean explicitRange) {
+ super(address, quantity, stringLength, dataType, config, explicitRange);
+ }
+
protected String getAddressStringPrefix() {
return ADDRESS_PREFIX;
}
@@ -81,21 +85,27 @@ public class ModbusTagInputRegister extends ModbusTag {
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 0000000000..09c3e31367
--- /dev/null
+++ b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusArrayParityTest.java
@@ -0,0 +1,57 @@
... 5081 lines suppressed ...