diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
index a7056144..6969602f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -499,6 +499,14 @@ public sealed class S7Driver
if (tag.ArrayCount is >= 1)
return await ReadArrayAsync(plc, tag, addr, ct).ConfigureAwait(false);
+ // Wide/structured scalar path (Int64/UInt64/Float64/String/DateTime): S7.Net's string
+ // ReadAsync only decodes 1/2/4-byte size suffixes, so these can't go through the narrow
+ // path below. Read a contiguous byte block at the start byte and decode it big-endian —
+ // mirrors the array path's buffer read. Timer/Counter are excluded here (IsBufferType
+ // gates on Area) and keep using the narrow path for now (broadened in a follow-up).
+ if (IsBufferType(tag, addr))
+ return await ReadScalarBlockAsync(plc, tag, addr, ct).ConfigureAwait(false);
+
// 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
@@ -536,6 +544,134 @@ public sealed class S7Driver
return DecodeArrayBlock(tag, addr, block);
}
+ ///
+ /// True when this tag must be read/written through the byte-buffer codec rather than
+ /// S7.Net's string path. The wide/structured types (Int64/UInt64/Float64/String/DateTime)
+ /// are byte-anchored (DBB /MB /IB /QB ) and decoded from a
+ /// contiguous block — S7.Net's string ReadAsync only understands 1/2/4-byte size
+ /// suffixes, so they can't use the narrow path. Timer/Counter areas are deliberately
+ /// excluded (they still route through the narrow path for now); a follow-up adds them to
+ /// this seam. The init guard already enforces byte-addressing + the Timer→Float64 /
+ /// Counter→Int32 type constraints, so by the time a tag reaches here it is well-formed.
+ ///
+ /// Tag definition carrying the .
+ /// Parsed address — its excludes Timer/Counter from the seam.
+ /// true if the tag routes through the byte-buffer codec.
+ private static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) =>
+ addr.Area is not S7Area.Timer and not S7Area.Counter
+ && tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
+ or S7DataType.String or S7DataType.DateTime;
+
+ ///
+ /// Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's
+ /// buffer-based Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct) (a single
+ /// PLC round-trip), then hands the raw block to the pure
+ /// decode. Mirrors 's shape.
+ ///
+ private async Task ReadScalarBlockAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
+ {
+ var width = ScalarByteWidth(tag);
+ var block = await plc.ReadBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, width, ct)
+ .ConfigureAwait(false)
+ ?? throw new System.IO.InvalidDataException($"S7.Net returned null block for '{tag.Address}'");
+
+ return DecodeScalarBlock(tag, addr, block);
+ }
+
+ ///
+ /// Byte width of one wide/structured scalar. Int64/UInt64/Float64/DateTime are 8 bytes;
+ /// an S7 STRING occupies StringLength + 2 bytes (the two-byte header carries the
+ /// declared max length and the current actual length). Timer/Counter widths land in a
+ /// follow-up. Throws for any non-buffer type — defensive; gates
+ /// this so a non-buffer type never reaches here.
+ ///
+ /// Tag definition carrying the and (for strings) StringLength .
+ /// The byte width to read/write for this scalar.
+ internal static int ScalarByteWidth(S7TagDefinition tag) => tag.DataType switch
+ {
+ S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
+ S7DataType.DateTime => 8,
+ S7DataType.String => tag.StringLength + 2,
+ _ => throw new InvalidOperationException(
+ $"S7 ScalarByteWidth called for non-buffer type {tag.DataType} (tag '{tag.Name}')"),
+ };
+
+ ///
+ /// Pure decode step — turns a raw S7 (big-endian) byte block into the wide scalar's CLR
+ /// value (long / ulong / double ), boxed as . No
+ /// network I/O — factored out of so the codec is
+ /// unit-testable against a known block without a live PLC (S7.Net ships no in-process
+ /// fake). Mirrors .
+ ///
+ /// Tag definition carrying the wide .
+ /// Parsed address (carried for the error surface + the Timer/Counter seam).
+ /// Raw contiguous byte block read from the PLC (length == ).
+ /// The decoded scalar value boxed as .
+ internal static object DecodeScalarBlock(S7TagDefinition tag, S7ParsedAddress addr, byte[] block)
+ {
+ // Timer/Counter decode added in a follow-up — a branch on addr.Area prepends here.
+ // Each numeric arm is boxed to object explicitly: a bare switch expression would unify
+ // long/ulong/double to their common type (double) and box THAT, mis-typing Int64/UInt64.
+ return tag.DataType switch
+ {
+ S7DataType.Int64 => (object)System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(block),
+ S7DataType.UInt64 => System.Buffers.Binary.BinaryPrimitives.ReadUInt64BigEndian(block),
+ S7DataType.Float64 => System.Buffers.Binary.BinaryPrimitives.ReadDoubleBigEndian(block),
+
+ S7DataType.String => throw new NotSupportedException(
+ "S7 String scalar reads land in a follow-up PR"),
+ S7DataType.DateTime => throw new NotSupportedException(
+ "S7 DateTime scalar reads land in a follow-up PR"),
+
+ _ => throw new System.IO.InvalidDataException(
+ $"S7 scalar Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
+ $"'{tag.Address}' parsed as Size={addr.Size}"),
+ };
+ }
+
+ ///
+ /// Pure encode step — turns the caller's value into the raw S7 (big-endian) byte block
+ /// for a wide scalar write. No network I/O — factored out of
+ /// so the codec is unit-testable (mirrors
+ /// ).
+ ///
+ /// Tag definition carrying the wide .
+ /// Value to encode (coerced via Convert.To* ).
+ /// The big-endian byte block to write.
+ internal static byte[] EncodeScalarBlock(S7TagDefinition tag, object? value)
+ {
+ switch (tag.DataType)
+ {
+ case S7DataType.Int64:
+ {
+ var b = new byte[8];
+ System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(b, Convert.ToInt64(value));
+ return b;
+ }
+ case S7DataType.UInt64:
+ {
+ var b = new byte[8];
+ System.Buffers.Binary.BinaryPrimitives.WriteUInt64BigEndian(b, Convert.ToUInt64(value));
+ return b;
+ }
+ case S7DataType.Float64:
+ {
+ var b = new byte[8];
+ System.Buffers.Binary.BinaryPrimitives.WriteDoubleBigEndian(b, Convert.ToDouble(value));
+ return b;
+ }
+
+ case S7DataType.String:
+ throw new NotSupportedException("S7 String scalar writes land in a follow-up PR");
+ case S7DataType.DateTime:
+ throw new NotSupportedException("S7 DateTime scalar writes land in a follow-up PR");
+
+ default:
+ throw new InvalidOperationException(
+ $"S7 EncodeScalarBlock called for non-buffer type {tag.DataType} (tag '{tag.Name}')");
+ }
+ }
+
/// Width in bytes of one array element for the given access size. Bit elements are
/// byte-granular over the wire (one byte per bool), so they cost 1 byte each.
/// The parsed access width.
@@ -770,6 +906,23 @@ public sealed class S7Driver
private async Task WriteOneAsync(Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
{
+ // Parse the address the same way ReadOneAsync does: authored tags pre-parse at init
+ // (_parsedByName); an equipment-tag ref (resolved transiently) parses on demand. Needed
+ // here so the wide-type write can byte-address the block (the narrow path below addresses
+ // by the raw address string instead).
+ var addr = _parsedByName.TryGetValue(tag.Name, out var parsed)
+ ? parsed
+ : S7AddressParser.Parse(tag.Address);
+
+ // Wide/structured scalar path: encode the value to a big-endian byte block and write it
+ // at the start byte via S7.Net's buffer-based WriteBytesAsync. Mirrors the read seam;
+ // the narrow string-based write below stays unchanged for 1/2/4-byte types.
+ if (IsBufferType(tag, addr))
+ {
+ await WriteScalarBlockAsync(plc, tag, addr, value, 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
@@ -778,6 +931,19 @@ public sealed class S7Driver
await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false);
}
+ ///
+ /// Writes a wide/structured scalar as ONE contiguous byte block via S7.Net's
+ /// buffer-based Plc.WriteBytesAsync(DataType, db, startByteAdr, byte[] value, ct) .
+ /// The pure produces the big-endian bytes; this method
+ /// owns only the network I/O (mirrors ).
+ ///
+ private async Task WriteScalarBlockAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, object? value, CancellationToken ct)
+ {
+ var bytes = EncodeScalarBlock(tag, value);
+ await plc.WriteBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, bytes, ct)
+ .ConfigureAwait(false);
+ }
+
///
/// Pure boxing step — converts the caller's value into the unsigned wire type that
/// S7.Net's Plc.WriteAsync expects for each address size (bool → bool, byte
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs
index 90575033..7e54b178 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs
@@ -171,23 +171,7 @@ public sealed class S7ArrayReadTests
// ── Discovery — IsArray / ArrayDim flip ───────────────────────────────────────────────
- private sealed class RecordingBuilder : IAddressSpaceBuilder
- {
- public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
- public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
- public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attr)
- {
- Variables.Add((browseName, attr));
- return new Handle();
- }
- public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
- public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
- private sealed class Handle : IVariableHandle
- {
- public string FullReference => "stub";
- public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotImplementedException();
- }
- }
+ // RecordingAddressSpaceBuilder lives in S7TestBuilders.cs — shared across the S7 test suite.
/// Verifies an array tag is discovered with IsArray=true and ArrayDim=count.
[Fact]
@@ -204,7 +188,7 @@ public sealed class S7ArrayReadTests
};
using var drv = new S7Driver(opts, "s7-arr-disco");
- var builder = new RecordingBuilder();
+ var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
var scalar = builder.Variables.Single(v => v.Name == "Scalar").Attr;
@@ -229,7 +213,7 @@ public sealed class S7ArrayReadTests
};
using var drv = new S7Driver(opts, "s7-arr-one");
- var builder = new RecordingBuilder();
+ var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
var one = builder.Variables.Single().Attr;
@@ -248,7 +232,7 @@ public sealed class S7ArrayReadTests
};
using var drv = new S7Driver(opts, "s7-arr-null");
- var builder = new RecordingBuilder();
+ var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
var scalar = builder.Variables.Single().Attr;
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs
index a437e46f..76bfe731 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs
@@ -13,56 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
[Trait("Category", "Unit")]
public sealed class S7DiscoveryAndSubscribeTests
{
- private sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
- {
- public readonly List Folders = new();
- public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
-
- /// Adds a folder to the address space.
- /// The browse name of the folder.
- /// The display name of the folder.
- /// This builder instance for method chaining.
- public IAddressSpaceBuilder Folder(string browseName, string displayName)
- {
- Folders.Add(browseName);
- return this;
- }
-
- /// Adds a variable to the address space.
- /// The browse name of the variable.
- /// The display name of the variable.
- /// The attribute information for the variable.
- /// A handle to the created variable.
- public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
- {
- Variables.Add((browseName, attributeInfo));
- return new StubHandle();
- }
-
- /// Adds a property to a variable.
- /// The browse name of the property.
- /// The data type of the property.
- /// The initial value of the property.
- public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
-
- /// Attaches an alarm condition to a variable.
- /// The variable to attach the alarm to.
- /// The name of the alarm.
- /// The alarm information.
- public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
-
- private sealed class StubHandle : IVariableHandle
- {
- /// Gets the full reference of the variable.
- public string FullReference => "stub";
-
- /// Marks this variable as an alarm condition.
- /// The alarm condition information.
- /// An alarm condition sink.
- public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
- => throw new NotImplementedException("S7 driver never calls this — no alarm surfacing");
- }
- }
+ // RecordingAddressSpaceBuilder lives in S7TestBuilders.cs — shared across the S7 test suite.
/// Verifies that DiscoverAsync projects every configured tag into the address space.
[Fact]
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs
index fe6e8c74..647b13e1 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs
@@ -7,8 +7,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
///
/// Regression tests for the remaining code-review findings closed against the S7 driver:
/// Driver.S7-003 (Read/WriteAsync null-arg validation), Driver.S7-009 (poll-loop health
-/// update + backoff), Driver.S7-010 (Dispose without sync-over-async), and Driver.S7-013
-/// (reject not-yet-implemented S7DataType values at init).
+/// update + backoff), Driver.S7-010 (Dispose without sync-over-async), and the Phase 4d
+/// guard-(b) (wide/structured types must be byte-addressed). The wide types are no longer
+/// rejected wholesale at init — the 8-byte numerics round-trip through the byte-buffer codec
+/// (see ); only a wide type at a non-byte address is rejected.
///
[Trait("Category", "Unit")]
public sealed class S7DriverCodeReviewFixTests2
@@ -136,31 +138,36 @@ public sealed class S7DriverCodeReviewFixTests2
Should.NotThrow(() => drv.Dispose());
}
- // ── Driver.S7-013 — Reject not-yet-implemented S7DataType values at init ─────────────
+ // ── Phase 4d guard-(b) — wide/structured types must be byte-addressed ────────────────
- /// Verifies that Initialize rejects not-yet-implemented data types with NotSupportedException.
- /// The S7 data type that is not yet implemented.
+ ///
+ /// Verifies the init guard rejects a wide/structured type authored at a non-byte address
+ /// (here DB1.DBD0 , a DWord). Phase 4d wired the 8-byte numerics (Int64/UInt64/
+ /// Float64) through the byte-buffer codec — see for the
+ /// round-trip coverage — but they (and the still-deferred String/DateTime) decode from a
+ /// byte-anchored block (DBB /MB /IB /QB ). A non-byte suffix would
+ /// mis-frame the value, so RejectUnsupportedTagConfigs guard-(b) fails the config
+ /// fast at init rather than as a misleading per-read fault.
+ ///
+ /// A wide/structured S7 data type authored at a non-byte address.
[Theory]
[InlineData(S7DataType.Int64)]
[InlineData(S7DataType.UInt64)]
[InlineData(S7DataType.Float64)]
[InlineData(S7DataType.String)]
[InlineData(S7DataType.DateTime)]
- public async Task Initialize_rejects_not_yet_implemented_data_type_with_NotSupportedException(S7DataType dt)
+ public async Task Initialize_rejects_wide_type_at_non_byte_address_with_NotSupportedException(S7DataType dt)
{
- // A tag declared with one of the not-yet-wired data types parses cleanly and creates
- // an OPC UA node via DiscoverAsync — then every Read/Write of it returns BadNotSupported.
- // The half-implemented type must be rejected at init so a site can't deploy a config
- // that produces dead nodes (Driver.S7-013).
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
- // Use a DB.DBD address — the parser accepts it for every data type. The init guard
- // must fault on the data-type rather than on the address.
+ // DB1.DBD0 is a DWord (non-byte) address — guard-(b) must fault on the address shape
+ // for a wide type. (At a byte address like DB1.DBB0 the 8-byte numerics round-trip;
+ // see S7ScalarBlockTests.)
Tags = [new S7TagDefinition("X", "DB1.DBD0", dt)],
};
- using var drv = new S7Driver(opts, $"s7-bad-dt-{dt}");
+ using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}");
var ex = await Should.ThrowAsync(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs
new file mode 100644
index 00000000..84aaba7a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs
@@ -0,0 +1,191 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
+
+///
+/// Unit tests for the S7 wide-type (8-byte numeric) byte-buffer codec: the pure
+/// /
+/// helpers and . These decode/encode an
+/// Int64/UInt64/LReal (Float64) scalar from a contiguous big-endian byte block — the
+/// network I/O half (Plc.ReadBytesAsync /WriteBytesAsync ) has no in-process
+/// fake so only the codec is unit-proven (mirrors ).
+/// String/DateTime decode is a deferred stub here (T3/T4); this file pins the
+/// NotSupportedException contract those land against.
+///
+[Trait("Category", "Unit")]
+public sealed class S7ScalarBlockTests
+{
+ // ── Helpers ──────────────────────────────────────────────────────────────────────────
+
+ // Wide scalars are byte-anchored: DB{n}.DBB{offset}, parser yields S7Size.Byte.
+ private static S7TagDefinition Tag(S7DataType dt, int stringLength = 254) =>
+ new("WideTag", "DB1.DBB0", dt, StringLength: stringLength);
+
+ private static S7ParsedAddress Addr() =>
+ new(S7Area.DataBlock, DbNumber: 1, S7Size.Byte, ByteOffset: 0, BitOffset: 0);
+
+ // S7 is big-endian: most-significant byte first.
+ private static byte[] BeUInt64(ulong v)
+ {
+ var b = new byte[8];
+ for (var i = 0; i < 8; i++)
+ b[i] = (byte)(v >> (56 - i * 8));
+ return b;
+ }
+
+ // ── ScalarByteWidth ───────────────────────────────────────────────────────────────────
+
+ /// Verifies the 8-byte numeric widths and the String/DateTime widths.
+ [Theory]
+ [InlineData(S7DataType.Int64, 8)]
+ [InlineData(S7DataType.UInt64, 8)]
+ [InlineData(S7DataType.Float64, 8)]
+ [InlineData(S7DataType.DateTime, 8)]
+ public void ScalarByteWidth_fixed_width_types(S7DataType dt, int expected)
+ => S7Driver.ScalarByteWidth(Tag(dt)).ShouldBe(expected);
+
+ /// Verifies String width is StringLength + 2 (S7 STRING header: max-len + actual-len).
+ [Fact]
+ public void ScalarByteWidth_String_is_length_plus_two()
+ => S7Driver.ScalarByteWidth(Tag(S7DataType.String, stringLength: 10)).ShouldBe(12);
+
+ // ── DecodeScalarBlock — Int64 ─────────────────────────────────────────────────────────
+
+ /// Verifies an Int64 block decodes from big-endian bytes.
+ [Fact]
+ public void DecodeScalarBlock_Int64_reads_big_endian()
+ {
+ var block = BeUInt64(0x0123456789ABCDEFUL);
+ // First byte is the most-significant byte (0x01) — proves big-endian, not little-endian.
+ block[0].ShouldBe((byte)0x01);
+
+ var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Int64), Addr(), block);
+ result.ShouldBeOfType().ShouldBe(0x0123456789ABCDEFL);
+ }
+
+ /// Verifies a negative Int64 decodes correctly (two's complement, high bit set).
+ [Fact]
+ public void DecodeScalarBlock_Int64_negative()
+ {
+ var block = BeUInt64(unchecked((ulong)-2L)); // 0xFFFF_FFFF_FFFF_FFFE
+ var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Int64), Addr(), block);
+ result.ShouldBeOfType().ShouldBe(-2L);
+ }
+
+ // ── DecodeScalarBlock — UInt64 ────────────────────────────────────────────────────────
+
+ /// Verifies a UInt64 block decodes a value larger than long.MaxValue.
+ [Fact]
+ public void DecodeScalarBlock_UInt64_reads_value_above_long_max()
+ {
+ var block = BeUInt64(ulong.MaxValue); // 0xFFFF_FFFF_FFFF_FFFF
+ var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.UInt64), Addr(), block);
+ result.ShouldBeOfType().ShouldBe(ulong.MaxValue);
+ }
+
+ // ── DecodeScalarBlock — Float64 (LReal) ───────────────────────────────────────────────
+
+ /// Verifies a Float64 (LReal) block decodes from IEEE-754 big-endian.
+ [Fact]
+ public void DecodeScalarBlock_Float64_reads_ieee754_big_endian()
+ {
+ var bits = unchecked((ulong)BitConverter.DoubleToInt64Bits(Math.PI));
+ var block = BeUInt64(bits);
+ var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Float64), Addr(), block);
+ result.ShouldBeOfType().ShouldBe(Math.PI, tolerance: 1e-12);
+ }
+
+ // ── EncodeScalarBlock — big-endian byte production ────────────────────────────────────
+
+ /// Verifies Int64 encodes to big-endian bytes (MSB first).
+ [Fact]
+ public void EncodeScalarBlock_Int64_writes_big_endian()
+ {
+ var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.Int64), 0x0123456789ABCDEFL);
+ bytes.Length.ShouldBe(8);
+ bytes.ShouldBe(BeUInt64(0x0123456789ABCDEFUL));
+ bytes[0].ShouldBe((byte)0x01); // MSB first — little-endian regression guard.
+ }
+
+ /// Verifies UInt64 encodes to big-endian bytes.
+ [Fact]
+ public void EncodeScalarBlock_UInt64_writes_big_endian()
+ {
+ var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.UInt64), ulong.MaxValue);
+ bytes.ShouldBe(BeUInt64(ulong.MaxValue));
+ }
+
+ /// Verifies Float64 encodes to IEEE-754 big-endian bytes.
+ [Fact]
+ public void EncodeScalarBlock_Float64_writes_ieee754_big_endian()
+ {
+ var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.Float64), Math.PI);
+ bytes.ShouldBe(BeUInt64(unchecked((ulong)BitConverter.DoubleToInt64Bits(Math.PI))));
+ }
+
+ // ── Round-trip identity (encode → decode) ─────────────────────────────────────────────
+
+ /// Verifies Int64 round-trips through encode→decode for positive, negative and edge values.
+ [Theory]
+ [InlineData(0L)]
+ [InlineData(1L)]
+ [InlineData(-1L)]
+ [InlineData(long.MaxValue)]
+ [InlineData(long.MinValue)]
+ [InlineData(0x0123456789ABCDEFL)]
+ public void Int64_round_trips(long value)
+ {
+ var tag = Tag(S7DataType.Int64);
+ var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
+ decoded.ShouldBeOfType().ShouldBe(value);
+ }
+
+ /// Verifies UInt64 round-trips, including a large value above long.MaxValue.
+ [Theory]
+ [InlineData(0UL)]
+ [InlineData(70_000UL)]
+ [InlineData(ulong.MaxValue)]
+ [InlineData(0x8000_0000_0000_0001UL)]
+ public void UInt64_round_trips(ulong value)
+ {
+ var tag = Tag(S7DataType.UInt64);
+ var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
+ decoded.ShouldBeOfType().ShouldBe(value);
+ }
+
+ /// Verifies Float64 (LReal) round-trips for representative doubles.
+ [Theory]
+ [InlineData(0.0)]
+ [InlineData(3.141592653589793)]
+ [InlineData(-2.5e-300)]
+ [InlineData(1.7976931348623157e308)]
+ public void Float64_round_trips(double value)
+ {
+ var tag = Tag(S7DataType.Float64);
+ var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
+ decoded.ShouldBeOfType().ShouldBe(value);
+ }
+
+ // ── Deferred-stub contract: String/DateTime throw NotSupportedException ────────────────
+
+ /// Verifies String/DateTime decode is a deferred stub (NotSupportedException — T3/T4).
+ /// The not-yet-implemented wide type.
+ [Theory]
+ [InlineData(S7DataType.String)]
+ [InlineData(S7DataType.DateTime)]
+ public void DecodeScalarBlock_String_or_DateTime_throws_NotSupported(S7DataType dt)
+ {
+ var tag = Tag(dt, stringLength: 10);
+ var block = new byte[S7Driver.ScalarByteWidth(tag)];
+ Should.Throw(() => S7Driver.DecodeScalarBlock(tag, Addr(), block));
+ }
+
+ /// Verifies String/DateTime encode is a deferred stub (NotSupportedException — T3/T4).
+ /// The not-yet-implemented wide type.
+ [Theory]
+ [InlineData(S7DataType.String)]
+ [InlineData(S7DataType.DateTime)]
+ public void EncodeScalarBlock_String_or_DateTime_throws_NotSupported(S7DataType dt)
+ => Should.Throw(() => S7Driver.EncodeScalarBlock(Tag(dt), "x"));
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TestBuilders.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TestBuilders.cs
new file mode 100644
index 00000000..7fda1f8e
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TestBuilders.cs
@@ -0,0 +1,65 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
+
+///
+/// Shared in-memory recorder used across the S7 driver
+/// test suite. Captures every folder + variable the driver projects via
+/// so a test can assert the discovered shape without a
+/// live address space. Replaces the three structurally-identical per-file recorders
+/// (RecordingAddressSpaceBuilder /RecordingBuilder /CapturingBuilder ) that
+/// previously duplicated this code.
+///
+internal sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
+{
+ /// Browse names of every folder the driver created, in order.
+ public readonly List Folders = new();
+
+ /// Every variable the driver created, as (browse name, attribute info) pairs.
+ public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
+
+ /// Records the folder browse name and returns this builder for chaining.
+ /// The browse name of the folder.
+ /// The display name of the folder.
+ /// This builder instance for method chaining.
+ public IAddressSpaceBuilder Folder(string browseName, string displayName)
+ {
+ Folders.Add(browseName);
+ return this;
+ }
+
+ /// Records the variable's browse name + attribute info.
+ /// The browse name of the variable.
+ /// The display name of the variable.
+ /// The attribute information for the variable.
+ /// A stub handle.
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
+ {
+ Variables.Add((browseName, attributeInfo));
+ return new StubHandle();
+ }
+
+ /// No-op property sink — the S7 driver does not add properties.
+ /// The browse name of the property.
+ /// The data type of the property.
+ /// The initial value of the property.
+ public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
+
+ /// No-op alarm sink — the S7 driver never surfaces alarms.
+ /// The variable to attach the alarm to.
+ /// The name of the alarm.
+ /// The alarm information.
+ public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
+
+ private sealed class StubHandle : IVariableHandle
+ {
+ /// Gets the full reference of the variable.
+ public string FullReference => "stub";
+
+ /// Marks this variable as an alarm condition.
+ /// The alarm condition information.
+ /// An alarm condition sink.
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
+ => throw new NotImplementedException("S7 driver never calls this — no alarm surfacing");
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TypeMappingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TypeMappingTests.cs
index 81f45ba0..85db687e 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TypeMappingTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TypeMappingTests.cs
@@ -194,49 +194,10 @@ public sealed class S7TypeMappingTests
// ── MapDataType (via DiscoverAsync) — Int64/UInt64 now map to their own members ───────
- // MapDataType is private; reach it through DiscoverAsync with a capturing builder — the
- // same indirection S7DiscoveryAndSubscribeTests uses. T1 split the formerly-lossy
- // Int64/UInt64 → Int32 line so 64-bit tags surface as the matching DriverDataType.
-
- private sealed class CapturingBuilder : IAddressSpaceBuilder
- {
- public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
-
- /// Records the folder and returns this builder for chaining.
- /// The browse name of the folder.
- /// The display name of the folder.
- /// This builder instance.
- public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
-
- /// Records the variable's name + attribute info.
- /// The browse name of the variable.
- /// The display name of the variable.
- /// The attribute information for the variable.
- /// A stub handle.
- public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
- {
- Variables.Add((browseName, attributeInfo));
- return new StubHandle();
- }
-
- /// No-op property sink.
- /// The browse name of the property.
- /// The data type of the property.
- /// The initial value of the property.
- public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
-
- private sealed class StubHandle : IVariableHandle
- {
- /// Gets the full reference of the variable.
- public string FullReference => "stub";
-
- /// Marks this variable as an alarm condition.
- /// The alarm condition information.
- /// An alarm condition sink.
- public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
- => throw new NotImplementedException("S7 driver never calls this");
- }
- }
+ // MapDataType is private; reach it through DiscoverAsync with the shared
+ // RecordingAddressSpaceBuilder (S7TestBuilders.cs) — the same indirection
+ // S7DiscoveryAndSubscribeTests uses. T1 split the formerly-lossy Int64/UInt64 → Int32 line
+ // so 64-bit tags surface as the matching DriverDataType.
/// Verifies that an Int64 tag discovers a node with DriverDataType.Int64 (no longer lossily mapped to Int32).
[Fact]
@@ -249,7 +210,7 @@ public sealed class S7TypeMappingTests
};
using var drv = new S7Driver(opts, "s7-i64-map");
- var builder = new CapturingBuilder();
+ var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Variables.Single(v => v.Name == "Counter64").Attr.DriverDataType
@@ -267,7 +228,7 @@ public sealed class S7TypeMappingTests
};
using var drv = new S7Driver(opts, "s7-u64-map");
- var builder = new CapturingBuilder();
+ var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Variables.Single(v => v.Name == "Total64").Attr.DriverDataType