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 ...