Auto: s7-a1 — 64-bit scalar types

Closes the NotSupportedException cliff for S7 Float64/Int64/UInt64.

- S7Size enum gains LWord (8 bytes); parser accepts DBLD/DBL on data
  blocks and LD on M/I/Q (e.g. DB1.DBLD0, DB1.DBL8, MLD0, ILD8, QLD16).
- S7Driver.ReadOneAsync / WriteOneAsync issue ReadBytesAsync /
  WriteBytesAsync for 64-bit types and convert big-endian via
  System.Buffers.Binary.BinaryPrimitives. S7's wire format is BE.
- Internal MapArea(S7Area) helper translates to S7.Net DataType.
- MapDataType now surfaces native DriverDataType for Int16/UInt16/
  UInt32/Int64/UInt64 instead of collapsing them all to Int32.

Tests: parser theories cover DBLD/DBL/MLD/ILD/QLD; discovery test
asserts the 64-bit DriverDataType mapping. 64/64 passing.

Closes #287
This commit is contained in:
Joseph Doherty
2026-04-25 16:16:23 -04:00
parent c6c694b69e
commit d1699af609
4 changed files with 156 additions and 28 deletions

View File

@@ -26,6 +26,8 @@ public enum S7Size
Byte, // B Byte, // B
Word, // W — 16-bit Word, // W — 16-bit
DWord, // D — 32-bit DWord, // D — 32-bit
LWord, // LD / DBL — 64-bit (LInt/ULInt/LReal). S7.Net has no native size suffix; the
// driver issues an 8-byte ReadBytes and converts big-endian in-process.
} }
/// <summary> /// <summary>
@@ -48,9 +50,12 @@ public readonly record struct S7ParsedAddress(
/// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5: /// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5:
/// <list type="bullet"> /// <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>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>DB{n}.{DBLD|DBL}{offset}</c> — 64-bit (LInt / ULInt / LReal) e.g. <c>DB1.DBLD0</c>, <c>DB1.DBL8</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>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>M{LD}{offset}</c> — 64-bit Merker, e.g. <c>MLD0</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>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>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>I{LD}{offset}</c> / <c>Q{LD}{offset}</c> — 64-bit Input/Output, e.g. <c>ILD0</c>, <c>QLD0</c></item>
/// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</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> /// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
/// </list> /// </list>
@@ -130,18 +135,36 @@ public static class S7AddressParser
throw new FormatException($"S7 DB number in '{s}' must be a positive integer"); throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
if (!tail.StartsWith("DB") || tail.Length < 4) if (!tail.StartsWith("DB") || tail.Length < 4)
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D}}"); throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D|LD|L}}");
var sizeChar = tail[2]; // 64-bit suffixes are two-letter (LD or DBL-as-prefix). Detect them up front so the
var offsetStart = 3; // single-char switch below stays readable. "DBLD" is the symmetric extension of
var size = sizeChar switch // DBX/DBB/DBW/DBD; "DBL" is the shorter Siemens "long" alias accepted as an alternate.
S7Size size;
int offsetStart;
if (tail.Length >= 5 && tail[2] == 'L' && tail[3] == 'D')
{ {
'X' => S7Size.Bit, size = S7Size.LWord;
'B' => S7Size.Byte, offsetStart = 4;
'W' => S7Size.Word, }
'D' => S7Size.DWord, else if (tail.Length >= 4 && tail[2] == 'L')
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"), {
}; size = S7Size.LWord;
offsetStart = 3;
}
else
{
var sizeChar = tail[2];
offsetStart = 3;
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/LD/L"),
};
}
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s); var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s);
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset); result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
@@ -156,17 +179,27 @@ public static class S7AddressParser
var first = rest[0]; var first = rest[0];
S7Size size; S7Size size;
int offsetStart; int offsetStart;
switch (first) // Two-char "LD" prefix (8-byte LWord) checked first so it doesn't get swallowed by
// the single-letter cases below.
if (rest.Length >= 2 && first == 'L' && rest[1] == 'D')
{ {
case 'B': size = S7Size.Byte; offsetStart = 1; break; size = S7Size.LWord;
case 'W': size = S7Size.Word; offsetStart = 1; break; offsetStart = 2;
case 'D': size = S7Size.DWord; offsetStart = 1; break; }
default: else
// No size prefix => bit-level address requires explicit .bit. Size stays Bit; {
// ParseOffsetAndOptionalBit will demand the dot. switch (first)
size = S7Size.Bit; {
offsetStart = 0; case 'B': size = S7Size.Byte; offsetStart = 1; break;
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); var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original);

View File

@@ -1,3 +1,4 @@
using System.Buffers.Binary;
using S7.Net; using S7.Net;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -220,6 +221,29 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct) private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
{ {
var addr = _parsedByName[tag.Name]; var addr = _parsedByName[tag.Name];
// 64-bit types: S7.Net's string-based ReadAsync has no LWord size suffix, so issue an
// 8-byte ReadBytesAsync and convert big-endian in-process. Wire order on S7 is BE.
if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64)
{
if (addr.Size != S7Size.LWord)
throw new System.IO.InvalidDataException(
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
$"parsed as Size={addr.Size}; 64-bit types require an LD/DBL/DBLD suffix");
var bytes = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, 8, ct)
.ConfigureAwait(false);
if (bytes is null || bytes.Length != 8)
throw new System.IO.InvalidDataException($"S7.Net returned {bytes?.Length ?? 0} bytes for '{tag.Address}', expected 8");
return tag.DataType switch
{
S7DataType.Int64 => BinaryPrimitives.ReadInt64BigEndian(bytes),
S7DataType.UInt64 => BinaryPrimitives.ReadUInt64BigEndian(bytes),
S7DataType.Float64 => BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(bytes)),
_ => throw new InvalidOperationException(),
};
}
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on // S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum // the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below // specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
@@ -238,9 +262,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32), (S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32), (S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 reads land in a follow-up PR"),
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 reads land in a follow-up PR"),
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) reads land in a follow-up PR"),
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"), (S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"),
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"), (S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
@@ -250,6 +271,18 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
}; };
} }
/// <summary>Map driver-internal <see cref="S7Area"/> to S7.Net's <see cref="global::S7.Net.DataType"/>.</summary>
private static global::S7.Net.DataType MapArea(S7Area area) => area switch
{
S7Area.DataBlock => global::S7.Net.DataType.DataBlock,
S7Area.Memory => global::S7.Net.DataType.Memory,
S7Area.Input => global::S7.Net.DataType.Input,
S7Area.Output => global::S7.Net.DataType.Output,
S7Area.Timer => global::S7.Net.DataType.Timer,
S7Area.Counter => global::S7.Net.DataType.Counter,
_ => throw new InvalidOperationException($"Unknown S7Area {area}"),
};
// ---- IWritable ---- // ---- IWritable ----
public async Task<IReadOnlyList<WriteResult>> WriteAsync( public async Task<IReadOnlyList<WriteResult>> WriteAsync(
@@ -299,6 +332,34 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct) private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
{ {
// 64-bit types: S7.Net has no LWord-aware WriteAsync(string, object) overload, so emit
// the value as 8 big-endian bytes via WriteBytesAsync. Wire order on S7 is BE so a
// BinaryPrimitives.Write*BigEndian round-trips with the matching ReadOneAsync path.
if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64)
{
var addr = _parsedByName[tag.Name];
if (addr.Size != S7Size.LWord)
throw new InvalidOperationException(
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
$"parsed as Size={addr.Size}; 64-bit types require an LD/DBL/DBLD suffix");
var buf = new byte[8];
switch (tag.DataType)
{
case S7DataType.Int64:
BinaryPrimitives.WriteInt64BigEndian(buf, Convert.ToInt64(value));
break;
case S7DataType.UInt64:
BinaryPrimitives.WriteUInt64BigEndian(buf, Convert.ToUInt64(value));
break;
case S7DataType.Float64:
BinaryPrimitives.WriteUInt64BigEndian(buf, BitConverter.DoubleToUInt64Bits(Convert.ToDouble(value)));
break;
}
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, buf, ct).ConfigureAwait(false);
return;
}
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to // S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. // match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned // Our S7DataType lets the caller pass short/int/float; convert to the unsigned
@@ -313,9 +374,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)), S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)), S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 writes land in a follow-up PR"),
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 writes land in a follow-up PR"),
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) writes land in a follow-up PR"),
S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"), S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"),
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"), S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"), _ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
@@ -351,8 +409,12 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
{ {
S7DataType.Bool => DriverDataType.Boolean, S7DataType.Bool => DriverDataType.Boolean,
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32, S7DataType.Int16 => DriverDataType.Int16,
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1 S7DataType.UInt16 => DriverDataType.UInt16,
S7DataType.Int32 => DriverDataType.Int32,
S7DataType.UInt32 => DriverDataType.UInt32,
S7DataType.Int64 => DriverDataType.Int64,
S7DataType.UInt64 => DriverDataType.UInt64,
S7DataType.Float32 => DriverDataType.Float32, S7DataType.Float32 => DriverDataType.Float32,
S7DataType.Float64 => DriverDataType.Float64, S7DataType.Float64 => DriverDataType.Float64,
S7DataType.String => DriverDataType.String, S7DataType.String => DriverDataType.String,

View File

@@ -14,6 +14,8 @@ public sealed class S7AddressParserTests
[InlineData("DB1.DBB0", 1, S7Size.Byte, 0, 0)] [InlineData("DB1.DBB0", 1, S7Size.Byte, 0, 0)]
[InlineData("DB1.DBW0", 1, S7Size.Word, 0, 0)] [InlineData("DB1.DBW0", 1, S7Size.Word, 0, 0)]
[InlineData("DB1.DBD4", 1, S7Size.DWord, 4, 0)] [InlineData("DB1.DBD4", 1, S7Size.DWord, 4, 0)]
[InlineData("DB1.DBLD0", 1, S7Size.LWord, 0, 0)] // 64-bit long DWord
[InlineData("DB1.DBL8", 1, S7Size.LWord, 8, 0)] // 64-bit alt suffix (LReal)
[InlineData("DB10.DBW100", 10, S7Size.Word, 100, 0)] [InlineData("DB10.DBW100", 10, S7Size.Word, 100, 0)]
[InlineData("DB1.DBX15.3", 1, S7Size.Bit, 15, 3)] [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) public void Parse_data_block_addresses(string input, int db, S7Size size, int byteOff, int bitOff)
@@ -53,6 +55,9 @@ public sealed class S7AddressParserTests
[InlineData("QW0", S7Area.Output, S7Size.Word, 0, 0)] [InlineData("QW0", S7Area.Output, S7Size.Word, 0, 0)]
[InlineData("Q0.0", S7Area.Output, S7Size.Bit, 0, 0)] [InlineData("Q0.0", S7Area.Output, S7Size.Bit, 0, 0)]
[InlineData("QD4", S7Area.Output, S7Size.DWord, 4, 0)] [InlineData("QD4", S7Area.Output, S7Size.DWord, 4, 0)]
[InlineData("MLD0", S7Area.Memory, S7Size.LWord, 0, 0)] // 64-bit Merker
[InlineData("ILD8", S7Area.Input, S7Size.LWord, 8, 0)]
[InlineData("QLD16", S7Area.Output, S7Size.LWord, 16, 0)]
public void Parse_MIQ_addresses(string input, S7Area area, S7Size size, int byteOff, int bitOff) public void Parse_MIQ_addresses(string input, S7Area area, S7Size size, int byteOff, int bitOff)
{ {
var r = S7AddressParser.Parse(input); var r = S7AddressParser.Parse(input);

View File

@@ -65,6 +65,34 @@ public sealed class S7DiscoveryAndSubscribeTests
builder.Variables[2].Attr.DriverDataType.ShouldBe(DriverDataType.Float32); builder.Variables[2].Attr.DriverDataType.ShouldBe(DriverDataType.Float32);
} }
[Fact]
public async Task DiscoverAsync_maps_64bit_types_to_matching_DriverDataType()
{
// PR-S7-A1: 64-bit scalar types must surface with their native DriverDataType
// (not collapse to Int32) so the OPC UA address-space layer publishes the right
// BuiltInType. Address suffixes: DBLD (DB long-DWord), MLD/ILD/QLD (M/I/Q long-DWord).
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Tags =
[
new("BigInt", "DB1.DBLD0", S7DataType.Int64),
new("BigUInt", "DB1.DBLD8", S7DataType.UInt64),
new("BigDouble", "DB1.DBLD16", S7DataType.Float64),
new("MerkerLong", "MLD0", S7DataType.Int64),
],
};
using var drv = new S7Driver(opts, "s7-64bit");
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Variables.Single(v => v.Name == "BigInt").Attr.DriverDataType.ShouldBe(DriverDataType.Int64);
builder.Variables.Single(v => v.Name == "BigUInt").Attr.DriverDataType.ShouldBe(DriverDataType.UInt64);
builder.Variables.Single(v => v.Name == "BigDouble").Attr.DriverDataType.ShouldBe(DriverDataType.Float64);
builder.Variables.Single(v => v.Name == "MerkerLong").Attr.DriverDataType.ShouldBe(DriverDataType.Int64);
}
[Fact] [Fact]
public async Task DiscoverAsync_propagates_WriteIdempotent_from_tag_to_attribute_info() public async Task DiscoverAsync_propagates_WriteIdempotent_from_tag_to_attribute_info()
{ {