Phase 3 PR 63 -- S7AddressParser (DB/M/I/Q/T/C grammar) #62
216
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs
Normal file
216
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>DataType</c> enum (<c>DataBlock</c>, <c>Memory</c>,
|
||||
/// <c>Input</c>, <c>Output</c>, <c>Timer</c>, <c>Counter</c>) so the adapter layer translates.
|
||||
/// </summary>
|
||||
public enum S7Area
|
||||
{
|
||||
DataBlock,
|
||||
Memory, // M (Merker / marker byte)
|
||||
Input, // I (process-image input)
|
||||
Output, // Q (process-image output)
|
||||
Timer,
|
||||
Counter,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access width for a DB / M / I / Q address. Timers and counters are always 16-bit
|
||||
/// opaque (not user-addressable via size suffixes).
|
||||
/// </summary>
|
||||
public enum S7Size
|
||||
{
|
||||
Bit, // X
|
||||
Byte, // B
|
||||
Word, // W — 16-bit
|
||||
DWord, // D — 32-bit
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse"/>.
|
||||
/// </summary>
|
||||
/// <param name="Area">Memory area (DB, M, I, Q, T, C).</param>
|
||||
/// <param name="DbNumber">Data block number; only meaningful when <paramref name="Area"/> is <see cref="S7Area.DataBlock"/>.</param>
|
||||
/// <param name="Size">Access width. Always <see cref="S7Size.Word"/> for Timer and Counter.</param>
|
||||
/// <param name="ByteOffset">Byte offset into the area (for DB/M/I/Q) or the timer/counter number.</param>
|
||||
/// <param name="BitOffset">Bit position 0-7 when <paramref name="Size"/> is <see cref="S7Size.Bit"/>; 0 otherwise.</param>
|
||||
public readonly record struct S7ParsedAddress(
|
||||
S7Area Area,
|
||||
int DbNumber,
|
||||
S7Size Size,
|
||||
int ByteOffset,
|
||||
int BitOffset);
|
||||
|
||||
/// <summary>
|
||||
/// Parses Siemens S7 address strings into <see cref="S7ParsedAddress"/>. Accepts the
|
||||
/// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>DB{n}.DB{X|B|W|D}{offset}[.bit]</c> — e.g. <c>DB1.DBX0.0</c>, <c>DB1.DBW0</c>, <c>DB1.DBD4</c></item>
|
||||
/// <item><c>M{B|W|D}{offset}</c> or <c>M{offset}.{bit}</c> — e.g. <c>MB0</c>, <c>MW0</c>, <c>MD4</c>, <c>M0.0</c></item>
|
||||
/// <item><c>I{B|W|D}{offset}</c> or <c>I{offset}.{bit}</c> — e.g. <c>IB0</c>, <c>IW0</c>, <c>ID0</c>, <c>I0.0</c></item>
|
||||
/// <item><c>Q{B|W|D}{offset}</c> or <c>Q{offset}.{bit}</c> — e.g. <c>QB0</c>, <c>QW0</c>, <c>QD0</c>, <c>Q0.0</c></item>
|
||||
/// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</c></item>
|
||||
/// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
|
||||
/// </list>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// <c>BadInternalError</c> on every Read against that tag.
|
||||
/// </remarks>
|
||||
public static class S7AddressParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse an S7 address. Throws <see cref="FormatException"/> on any syntax error with
|
||||
/// the offending input echoed in the message so operators can correlate to the tag
|
||||
/// config that produced the fault.
|
||||
/// </summary>
|
||||
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)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try-parse variant for callers that can't afford an exception on bad input (e.g.
|
||||
/// config validation pages in the Admin UI). Returns <c>false</c> for any input that
|
||||
/// would throw from <see cref="Parse"/>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
119
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs
Normal file
119
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs
Normal file
@@ -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<FormatException>(() => 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user