From d1699af6097e5f29d15b0df741e33fa1607d7546 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 16:16:23 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20s7-a1=20=E2=80=94=2064-bit=20scalar=20t?= =?UTF-8?q?ypes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../S7AddressParser.cs | 73 ++++++++++++----- src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 78 +++++++++++++++++-- .../S7AddressParserTests.cs | 5 ++ .../S7DiscoveryAndSubscribeTests.cs | 28 +++++++ 4 files changed, 156 insertions(+), 28 deletions(-) diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs index 98c8387..be6641a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs @@ -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. } /// @@ -48,9 +50,12 @@ public readonly record struct S7ParsedAddress( /// 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 +/// DB{n}.{DBLD|DBL}{offset} — 64-bit (LInt / ULInt / LReal) e.g. DB1.DBLD0, DB1.DBL8 /// M{B|W|D}{offset} or M{offset}.{bit} — e.g. MB0, MW0, MD4, M0.0 +/// M{LD}{offset} — 64-bit Merker, e.g. MLD0 /// 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 +/// I{LD}{offset} / Q{LD}{offset} — 64-bit Input/Output, e.g. ILD0, QLD0 /// T{n} — e.g. T0, T15 /// C{n} — e.g. C0, C10 /// @@ -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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 52260e4..a1af29e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -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 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) }; } + /// Map driver-internal to S7.Net's . + 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> 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, diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs index 7e81db0..cf5fed8 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs @@ -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); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs index dc9d7dc..c6e7ca7 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs @@ -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() { -- 2.49.1