diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs
new file mode 100644
index 0000000..98c8387
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs
@@ -0,0 +1,216 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
+
+///
+/// Siemens S7 memory area. The driver's tag-address parser maps every S7 tag string into
+/// exactly one of these + an offset. Values match the on-wire S7 area codes only
+/// incidentally — S7.Net uses its own DataType enum (DataBlock, Memory,
+/// Input, Output, Timer, Counter) so the adapter layer translates.
+///
+public enum S7Area
+{
+ DataBlock,
+ Memory, // M (Merker / marker byte)
+ Input, // I (process-image input)
+ Output, // Q (process-image output)
+ Timer,
+ Counter,
+}
+
+///
+/// Access width for a DB / M / I / Q address. Timers and counters are always 16-bit
+/// opaque (not user-addressable via size suffixes).
+///
+public enum S7Size
+{
+ Bit, // X
+ Byte, // B
+ Word, // W — 16-bit
+ DWord, // D — 32-bit
+}
+
+///
+/// Parsed form of an S7 tag-address string. Produced by .
+///
+/// Memory area (DB, M, I, Q, T, C).
+/// Data block number; only meaningful when is .
+/// Access width. Always for Timer and Counter.
+/// Byte offset into the area (for DB/M/I/Q) or the timer/counter number.
+/// Bit position 0-7 when is ; 0 otherwise.
+public readonly record struct S7ParsedAddress(
+ S7Area Area,
+ int DbNumber,
+ S7Size Size,
+ int ByteOffset,
+ int BitOffset);
+
+///
+/// Parses Siemens S7 address strings into . Accepts the
+/// Siemens TIA-Portal / STEP 7 Classic syntax documented in docs/v2/driver-specs.md §5:
+///
+/// - DB{n}.DB{X|B|W|D}{offset}[.bit] — e.g. DB1.DBX0.0, DB1.DBW0, DB1.DBD4
+/// - M{B|W|D}{offset} or M{offset}.{bit} — e.g. MB0, MW0, MD4, M0.0
+/// - I{B|W|D}{offset} or I{offset}.{bit} — e.g. IB0, IW0, ID0, I0.0
+/// - Q{B|W|D}{offset} or Q{offset}.{bit} — e.g. QB0, QW0, QD0, Q0.0
+/// - T{n} — e.g. T0, T15
+/// - C{n} — e.g. C0, C10
+///
+/// Grammar is case-insensitive. Leading/trailing whitespace tolerated. Bit specifiers
+/// must be 0-7; byte offsets must be non-negative; DB numbers must be >= 1.
+///
+///
+/// Parse is deliberately strict — the parser rejects syntactic garbage up-front so a bad
+/// tag config fails at driver init time instead of surfacing as a misleading
+/// BadInternalError on every Read against that tag.
+///
+public static class S7AddressParser
+{
+ ///
+ /// Parse an S7 address. Throws on any syntax error with
+ /// the offending input echoed in the message so operators can correlate to the tag
+ /// config that produced the fault.
+ ///
+ public static S7ParsedAddress Parse(string address)
+ {
+ if (string.IsNullOrWhiteSpace(address))
+ throw new FormatException("S7 address must not be empty");
+ var s = address.Trim().ToUpperInvariant();
+
+ // --- DB{n}.DB{X|B|W|D}{offset}[.bit] ---
+ if (s.StartsWith("DB") && TryParseDataBlock(s, out var dbResult))
+ return dbResult;
+
+ if (s.Length < 2)
+ throw new FormatException($"S7 address '{address}' is too short to parse");
+
+ var areaChar = s[0];
+ var rest = s.Substring(1);
+
+ switch (areaChar)
+ {
+ case 'M': return ParseMIQ(S7Area.Memory, rest, address);
+ case 'I': return ParseMIQ(S7Area.Input, rest, address);
+ case 'Q': return ParseMIQ(S7Area.Output, rest, address);
+ case 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address);
+ case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address);
+ default:
+ throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C)");
+ }
+ }
+
+ ///
+ /// Try-parse variant for callers that can't afford an exception on bad input (e.g.
+ /// config validation pages in the Admin UI). Returns false for any input that
+ /// would throw from .
+ ///
+ public static bool TryParse(string address, out S7ParsedAddress result)
+ {
+ try
+ {
+ result = Parse(address);
+ return true;
+ }
+ catch (FormatException)
+ {
+ result = default;
+ return false;
+ }
+ }
+
+ private static bool TryParseDataBlock(string s, out S7ParsedAddress result)
+ {
+ result = default;
+ // Split on first '.': left side must be DB{n}, right side DB{X|B|W|D}{offset}[.bit]
+ var dot = s.IndexOf('.');
+ if (dot < 0) return false;
+ var head = s.Substring(0, dot); // DB{n}
+ var tail = s.Substring(dot + 1); // DB{X|B|W|D}{offset}[.bit]
+
+ if (head.Length < 3) return false;
+ if (!int.TryParse(head.AsSpan(2), out var dbNumber) || dbNumber < 1)
+ throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
+
+ if (!tail.StartsWith("DB") || tail.Length < 4)
+ throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D}}");
+
+ var sizeChar = tail[2];
+ var offsetStart = 3;
+ var size = sizeChar switch
+ {
+ 'X' => S7Size.Bit,
+ 'B' => S7Size.Byte,
+ 'W' => S7Size.Word,
+ 'D' => S7Size.DWord,
+ _ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"),
+ };
+
+ var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s);
+ result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
+ return true;
+ }
+
+ private static S7ParsedAddress ParseMIQ(S7Area area, string rest, string original)
+ {
+ if (rest.Length == 0)
+ throw new FormatException($"S7 address '{original}' has no offset");
+
+ var first = rest[0];
+ S7Size size;
+ int offsetStart;
+ switch (first)
+ {
+ case 'B': size = S7Size.Byte; offsetStart = 1; break;
+ case 'W': size = S7Size.Word; offsetStart = 1; break;
+ case 'D': size = S7Size.DWord; offsetStart = 1; break;
+ default:
+ // No size prefix => bit-level address requires explicit .bit. Size stays Bit;
+ // ParseOffsetAndOptionalBit will demand the dot.
+ size = S7Size.Bit;
+ offsetStart = 0;
+ break;
+ }
+
+ var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original);
+ return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset);
+ }
+
+ private static S7ParsedAddress ParseTimerOrCounter(S7Area area, string rest, string original)
+ {
+ if (rest.Length == 0)
+ throw new FormatException($"S7 address '{original}' has no {area} number");
+ if (!int.TryParse(rest, out var number) || number < 0)
+ throw new FormatException($"S7 {area} number in '{original}' must be a non-negative integer");
+ return new S7ParsedAddress(area, DbNumber: 0, S7Size.Word, number, BitOffset: 0);
+ }
+
+ private static (int byteOffset, int bitOffset) ParseOffsetAndOptionalBit(
+ string s, int start, S7Size size, string original)
+ {
+ var offsetEnd = start;
+ while (offsetEnd < s.Length && s[offsetEnd] >= '0' && s[offsetEnd] <= '9')
+ offsetEnd++;
+ if (offsetEnd == start)
+ throw new FormatException($"S7 address '{original}' has no byte-offset digits");
+
+ if (!int.TryParse(s.AsSpan(start, offsetEnd - start), out var byteOffset) || byteOffset < 0)
+ throw new FormatException($"S7 byte offset in '{original}' must be non-negative");
+
+ // No bit-suffix: done unless size is Bit with no prefix, which requires one.
+ if (offsetEnd == s.Length)
+ {
+ if (size == S7Size.Bit)
+ throw new FormatException($"S7 address '{original}' needs a .{{bit}} suffix for bit access");
+ return (byteOffset, 0);
+ }
+
+ if (s[offsetEnd] != '.')
+ throw new FormatException($"S7 address '{original}' has unexpected character after offset");
+
+ if (size != S7Size.Bit)
+ throw new FormatException($"S7 address '{original}' has a bit suffix but the size is {size} — bit access needs X (DB) or no size prefix (M/I/Q)");
+
+ if (!int.TryParse(s.AsSpan(offsetEnd + 1), out var bitOffset) || bitOffset is < 0 or > 7)
+ throw new FormatException($"S7 bit offset in '{original}' must be 0-7");
+
+ return (byteOffset, bitOffset);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs
new file mode 100644
index 0000000..7e81db0
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs
@@ -0,0 +1,119 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class S7AddressParserTests
+{
+ // --- Data blocks ---
+
+ [Theory]
+ [InlineData("DB1.DBX0.0", 1, S7Size.Bit, 0, 0)]
+ [InlineData("DB1.DBX0.7", 1, S7Size.Bit, 0, 7)]
+ [InlineData("DB1.DBB0", 1, S7Size.Byte, 0, 0)]
+ [InlineData("DB1.DBW0", 1, S7Size.Word, 0, 0)]
+ [InlineData("DB1.DBD4", 1, S7Size.DWord, 4, 0)]
+ [InlineData("DB10.DBW100", 10, S7Size.Word, 100, 0)]
+ [InlineData("DB1.DBX15.3", 1, S7Size.Bit, 15, 3)]
+ public void Parse_data_block_addresses(string input, int db, S7Size size, int byteOff, int bitOff)
+ {
+ var r = S7AddressParser.Parse(input);
+ r.Area.ShouldBe(S7Area.DataBlock);
+ r.DbNumber.ShouldBe(db);
+ r.Size.ShouldBe(size);
+ r.ByteOffset.ShouldBe(byteOff);
+ r.BitOffset.ShouldBe(bitOff);
+ }
+
+ [Theory]
+ [InlineData("db1.dbw0", 1, S7Size.Word, 0)]
+ [InlineData(" DB1.DBW0 ", 1, S7Size.Word, 0)] // trim whitespace
+ public void Parse_is_case_insensitive_and_trims(string input, int db, S7Size size, int off)
+ {
+ var r = S7AddressParser.Parse(input);
+ r.Area.ShouldBe(S7Area.DataBlock);
+ r.DbNumber.ShouldBe(db);
+ r.Size.ShouldBe(size);
+ r.ByteOffset.ShouldBe(off);
+ }
+
+ // --- M / I / Q ---
+
+ [Theory]
+ [InlineData("MB0", S7Area.Memory, S7Size.Byte, 0, 0)]
+ [InlineData("MW10", S7Area.Memory, S7Size.Word, 10, 0)]
+ [InlineData("MD4", S7Area.Memory, S7Size.DWord, 4, 0)]
+ [InlineData("M0.0", S7Area.Memory, S7Size.Bit, 0, 0)]
+ [InlineData("M255.7", S7Area.Memory, S7Size.Bit, 255, 7)]
+ [InlineData("IB0", S7Area.Input, S7Size.Byte, 0, 0)]
+ [InlineData("IW0", S7Area.Input, S7Size.Word, 0, 0)]
+ [InlineData("I0.0", S7Area.Input, S7Size.Bit, 0, 0)]
+ [InlineData("QB0", S7Area.Output, S7Size.Byte, 0, 0)]
+ [InlineData("QW0", S7Area.Output, S7Size.Word, 0, 0)]
+ [InlineData("Q0.0", S7Area.Output, S7Size.Bit, 0, 0)]
+ [InlineData("QD4", S7Area.Output, S7Size.DWord, 4, 0)]
+ public void Parse_MIQ_addresses(string input, S7Area area, S7Size size, int byteOff, int bitOff)
+ {
+ var r = S7AddressParser.Parse(input);
+ r.Area.ShouldBe(area);
+ r.DbNumber.ShouldBe(0);
+ r.Size.ShouldBe(size);
+ r.ByteOffset.ShouldBe(byteOff);
+ r.BitOffset.ShouldBe(bitOff);
+ }
+
+ // --- Timers / counters ---
+
+ [Theory]
+ [InlineData("T0", S7Area.Timer, 0)]
+ [InlineData("T15", S7Area.Timer, 15)]
+ [InlineData("C0", S7Area.Counter, 0)]
+ [InlineData("C10", S7Area.Counter, 10)]
+ public void Parse_timer_and_counter(string input, S7Area area, int number)
+ {
+ var r = S7AddressParser.Parse(input);
+ r.Area.ShouldBe(area);
+ r.ByteOffset.ShouldBe(number);
+ r.Size.ShouldBe(S7Size.Word, "timers + counters are 16-bit opaque");
+ }
+
+ // --- Reject garbage ---
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("Z0")] // unknown area
+ [InlineData("DB")] // no number or tail
+ [InlineData("DB1")] // no tail
+ [InlineData("DB1.")] // empty tail
+ [InlineData("DB1.DBX0")] // bit size without .bit
+ [InlineData("DB1.DBX0.8")] // bit 8 out of range
+ [InlineData("DB1.DBW0.0")] // word with bit suffix
+ [InlineData("DB0.DBW0")] // db 0 invalid
+ [InlineData("DBA.DBW0")] // non-numeric db
+ [InlineData("DB1.DBQ0")] // invalid size letter
+ [InlineData("M")] // no offset
+ [InlineData("M0")] // bit access needs .bit
+ [InlineData("M0.8")] // bit 8
+ [InlineData("MB-1")] // negative offset
+ [InlineData("MW")] // no offset digits
+ [InlineData("TA")] // non-numeric timer
+ public void Parse_rejects_invalid(string bad)
+ => Should.Throw(() => S7AddressParser.Parse(bad));
+
+ [Fact]
+ public void TryParse_returns_false_for_garbage_without_throwing()
+ {
+ S7AddressParser.TryParse("not-an-address", out var r).ShouldBeFalse();
+ r.ShouldBe(default);
+ }
+
+ [Fact]
+ public void TryParse_returns_true_for_valid_address()
+ {
+ S7AddressParser.TryParse("DB1.DBW0", out var r).ShouldBeTrue();
+ r.DbNumber.ShouldBe(1);
+ r.Size.ShouldBe(S7Size.Word);
+ }
+}