Merge pull request '[s7] S7 — 64-bit scalar types (LInt/ULInt/LReal/LWord)' (#336) from auto/s7/PR-S7-A1 into auto/driver-gaps
This commit was merged in pull request #336.
This commit is contained in:
@@ -26,6 +26,8 @@ public enum S7Size
|
||||
Byte, // B
|
||||
Word, // W — 16-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>
|
||||
@@ -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:
|
||||
/// <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}.{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{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>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>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
|
||||
/// </list>
|
||||
@@ -130,18 +135,36 @@ public static class S7AddressParser
|
||||
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}}");
|
||||
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D|LD|L}}");
|
||||
|
||||
var sizeChar = tail[2];
|
||||
var offsetStart = 3;
|
||||
var size = sizeChar switch
|
||||
// 64-bit suffixes are two-letter (LD or DBL-as-prefix). Detect them up front so the
|
||||
// single-char switch below stays readable. "DBLD" is the symmetric extension of
|
||||
// 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,
|
||||
'B' => S7Size.Byte,
|
||||
'W' => S7Size.Word,
|
||||
'D' => S7Size.DWord,
|
||||
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"),
|
||||
};
|
||||
size = S7Size.LWord;
|
||||
offsetStart = 4;
|
||||
}
|
||||
else if (tail.Length >= 4 && tail[2] == 'L')
|
||||
{
|
||||
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);
|
||||
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
|
||||
@@ -156,17 +179,27 @@ public static class S7AddressParser
|
||||
var first = rest[0];
|
||||
S7Size size;
|
||||
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;
|
||||
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;
|
||||
size = S7Size.LWord;
|
||||
offsetStart = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Buffers.Binary;
|
||||
using S7.Net;
|
||||
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)
|
||||
{
|
||||
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
|
||||
// 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
|
||||
@@ -238,9 +262,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)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.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 ----
|
||||
|
||||
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)
|
||||
{
|
||||
// 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
|
||||
// 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
|
||||
@@ -313,9 +374,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(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.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
|
||||
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
|
||||
@@ -351,8 +409,12 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
{
|
||||
S7DataType.Bool => DriverDataType.Boolean,
|
||||
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
|
||||
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1
|
||||
S7DataType.Int16 => DriverDataType.Int16,
|
||||
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.Float64 => DriverDataType.Float64,
|
||||
S7DataType.String => DriverDataType.String,
|
||||
|
||||
@@ -14,6 +14,8 @@ public sealed class S7AddressParserTests
|
||||
[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("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("DB1.DBX15.3", 1, S7Size.Bit, 15, 3)]
|
||||
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("Q0.0", S7Area.Output, S7Size.Bit, 0, 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)
|
||||
{
|
||||
var r = S7AddressParser.Parse(input);
|
||||
|
||||
@@ -65,6 +65,34 @@ public sealed class S7DiscoveryAndSubscribeTests
|
||||
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]
|
||||
public async Task DiscoverAsync_propagates_WriteIdempotent_from_tag_to_attribute_info()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user