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); + } +}