feat(s7): byte-buffer codec dispatch + Int64/UInt64/LReal scalar read+write
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>) 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.
|
||||
/// </summary>
|
||||
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/>.</param>
|
||||
/// <param name="addr">Parsed address — its <see cref="S7Area"/> excludes Timer/Counter from the seam.</param>
|
||||
/// <returns><c>true</c> if the tag routes through the byte-buffer codec.</returns>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's
|
||||
/// buffer-based <c>Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct)</c> (a single
|
||||
/// PLC round-trip), then hands the raw block to the pure <see cref="DecodeScalarBlock"/>
|
||||
/// decode. Mirrors <see cref="ReadArrayAsync"/>'s shape.
|
||||
/// </summary>
|
||||
private async Task<object> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Byte width of one wide/structured scalar. Int64/UInt64/Float64/DateTime are 8 bytes;
|
||||
/// an S7 STRING occupies <c>StringLength + 2</c> 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; <see cref="IsBufferType"/> gates
|
||||
/// this so a non-buffer type never reaches here.
|
||||
/// </summary>
|
||||
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/> and (for strings) <c>StringLength</c>.</param>
|
||||
/// <returns>The byte width to read/write for this scalar.</returns>
|
||||
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}')"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Pure decode step — turns a raw S7 (big-endian) byte block into the wide scalar's CLR
|
||||
/// value (<c>long</c> / <c>ulong</c> / <c>double</c>), boxed as <see cref="object"/>. No
|
||||
/// network I/O — factored out of <see cref="ReadScalarBlockAsync"/> so the codec is
|
||||
/// unit-testable against a known block without a live PLC (S7.Net ships no in-process
|
||||
/// fake). Mirrors <see cref="DecodeArrayBlock"/>.
|
||||
/// </summary>
|
||||
/// <param name="tag">Tag definition carrying the wide <see cref="S7DataType"/>.</param>
|
||||
/// <param name="addr">Parsed address (carried for the error surface + the Timer/Counter seam).</param>
|
||||
/// <param name="block">Raw contiguous byte block read from the PLC (length == <see cref="ScalarByteWidth"/>).</param>
|
||||
/// <returns>The decoded scalar value boxed as <see cref="object"/>.</returns>
|
||||
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}"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="WriteScalarBlockAsync"/> so the codec is unit-testable (mirrors
|
||||
/// <see cref="BoxValueForWrite"/>).
|
||||
/// </summary>
|
||||
/// <param name="tag">Tag definition carrying the wide <see cref="S7DataType"/>.</param>
|
||||
/// <param name="value">Value to encode (coerced via <c>Convert.To*</c>).</param>
|
||||
/// <returns>The big-endian byte block to write.</returns>
|
||||
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}')");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
/// <param name="size">The parsed access width.</param>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a wide/structured scalar as ONE contiguous byte block via S7.Net's
|
||||
/// buffer-based <c>Plc.WriteBytesAsync(DataType, db, startByteAdr, byte[] value, ct)</c>.
|
||||
/// The pure <see cref="EncodeScalarBlock"/> produces the big-endian bytes; this method
|
||||
/// owns only the network I/O (mirrors <see cref="ReadScalarBlockAsync"/>).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure boxing step — converts the caller's value into the unsigned wire type that
|
||||
/// S7.Net's <c>Plc.WriteAsync</c> expects for each address size (bool → bool, byte
|
||||
|
||||
@@ -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.
|
||||
|
||||
/// <summary>Verifies an array tag is discovered with IsArray=true and ArrayDim=count.</summary>
|
||||
[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;
|
||||
|
||||
@@ -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<string> Folders = new();
|
||||
public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
|
||||
|
||||
/// <summary>Adds a folder to the address space.</summary>
|
||||
/// <param name="browseName">The browse name of the folder.</param>
|
||||
/// <param name="displayName">The display name of the folder.</param>
|
||||
/// <returns>This builder instance for method chaining.</returns>
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add(browseName);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Adds a variable to the address space.</summary>
|
||||
/// <param name="browseName">The browse name of the variable.</param>
|
||||
/// <param name="displayName">The display name of the variable.</param>
|
||||
/// <param name="attributeInfo">The attribute information for the variable.</param>
|
||||
/// <returns>A handle to the created variable.</returns>
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add((browseName, attributeInfo));
|
||||
return new StubHandle();
|
||||
}
|
||||
|
||||
/// <summary>Adds a property to a variable.</summary>
|
||||
/// <param name="browseName">The browse name of the property.</param>
|
||||
/// <param name="dataType">The data type of the property.</param>
|
||||
/// <param name="value">The initial value of the property.</param>
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
/// <summary>Attaches an alarm condition to a variable.</summary>
|
||||
/// <param name="sourceVariable">The variable to attach the alarm to.</param>
|
||||
/// <param name="alarmName">The name of the alarm.</param>
|
||||
/// <param name="alarmInfo">The alarm information.</param>
|
||||
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
/// <summary>Gets the full reference of the variable.</summary>
|
||||
public string FullReference => "stub";
|
||||
|
||||
/// <summary>Marks this variable as an alarm condition.</summary>
|
||||
/// <param name="info">The alarm condition information.</param>
|
||||
/// <returns>An alarm condition sink.</returns>
|
||||
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.
|
||||
|
||||
/// <summary>Verifies that DiscoverAsync projects every configured tag into the address space.</summary>
|
||||
[Fact]
|
||||
|
||||
@@ -7,8 +7,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
/// <summary>
|
||||
/// 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 <see cref="S7ScalarBlockTests"/>); only a wide type at a non-byte address is rejected.
|
||||
/// </summary>
|
||||
[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 ────────────────
|
||||
|
||||
/// <summary>Verifies that Initialize rejects not-yet-implemented data types with NotSupportedException.</summary>
|
||||
/// <param name="dt">The S7 data type that is not yet implemented.</param>
|
||||
/// <summary>
|
||||
/// Verifies the init guard rejects a wide/structured type authored at a non-byte address
|
||||
/// (here <c>DB1.DBD0</c>, a DWord). Phase 4d wired the 8-byte numerics (Int64/UInt64/
|
||||
/// Float64) through the byte-buffer codec — see <see cref="S7ScalarBlockTests"/> for the
|
||||
/// round-trip coverage — but they (and the still-deferred String/DateTime) decode from a
|
||||
/// byte-anchored block (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>). A non-byte suffix would
|
||||
/// mis-frame the value, so <c>RejectUnsupportedTagConfigs</c> guard-(b) fails the config
|
||||
/// fast at init rather than as a misleading per-read fault.
|
||||
/// </summary>
|
||||
/// <param name="dt">A wide/structured S7 data type authored at a non-byte address.</param>
|
||||
[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<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the S7 wide-type (8-byte numeric) byte-buffer codec: the pure
|
||||
/// <see cref="S7Driver.DecodeScalarBlock"/> / <see cref="S7Driver.EncodeScalarBlock"/>
|
||||
/// helpers and <see cref="S7Driver.ScalarByteWidth"/>. These decode/encode an
|
||||
/// Int64/UInt64/LReal (Float64) scalar from a contiguous big-endian byte block — the
|
||||
/// network I/O half (<c>Plc.ReadBytesAsync</c>/<c>WriteBytesAsync</c>) has no in-process
|
||||
/// fake so only the codec is unit-proven (mirrors <see cref="S7ArrayReadTests"/>).
|
||||
/// String/DateTime decode is a deferred stub here (T3/T4); this file pins the
|
||||
/// NotSupportedException contract those land against.
|
||||
/// </summary>
|
||||
[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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies the 8-byte numeric widths and the String/DateTime widths.</summary>
|
||||
[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);
|
||||
|
||||
/// <summary>Verifies String width is StringLength + 2 (S7 STRING header: max-len + actual-len).</summary>
|
||||
[Fact]
|
||||
public void ScalarByteWidth_String_is_length_plus_two()
|
||||
=> S7Driver.ScalarByteWidth(Tag(S7DataType.String, stringLength: 10)).ShouldBe(12);
|
||||
|
||||
// ── DecodeScalarBlock — Int64 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies an Int64 block decodes from big-endian bytes.</summary>
|
||||
[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<long>().ShouldBe(0x0123456789ABCDEFL);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a negative Int64 decodes correctly (two's complement, high bit set).</summary>
|
||||
[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<long>().ShouldBe(-2L);
|
||||
}
|
||||
|
||||
// ── DecodeScalarBlock — UInt64 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies a UInt64 block decodes a value larger than long.MaxValue.</summary>
|
||||
[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<ulong>().ShouldBe(ulong.MaxValue);
|
||||
}
|
||||
|
||||
// ── DecodeScalarBlock — Float64 (LReal) ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies a Float64 (LReal) block decodes from IEEE-754 big-endian.</summary>
|
||||
[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<double>().ShouldBe(Math.PI, tolerance: 1e-12);
|
||||
}
|
||||
|
||||
// ── EncodeScalarBlock — big-endian byte production ────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies Int64 encodes to big-endian bytes (MSB first).</summary>
|
||||
[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.
|
||||
}
|
||||
|
||||
/// <summary>Verifies UInt64 encodes to big-endian bytes.</summary>
|
||||
[Fact]
|
||||
public void EncodeScalarBlock_UInt64_writes_big_endian()
|
||||
{
|
||||
var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.UInt64), ulong.MaxValue);
|
||||
bytes.ShouldBe(BeUInt64(ulong.MaxValue));
|
||||
}
|
||||
|
||||
/// <summary>Verifies Float64 encodes to IEEE-754 big-endian bytes.</summary>
|
||||
[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) ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies Int64 round-trips through encode→decode for positive, negative and edge values.</summary>
|
||||
[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<long>().ShouldBe(value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies UInt64 round-trips, including a large value above long.MaxValue.</summary>
|
||||
[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<ulong>().ShouldBe(value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies Float64 (LReal) round-trips for representative doubles.</summary>
|
||||
[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<double>().ShouldBe(value);
|
||||
}
|
||||
|
||||
// ── Deferred-stub contract: String/DateTime throw NotSupportedException ────────────────
|
||||
|
||||
/// <summary>Verifies String/DateTime decode is a deferred stub (NotSupportedException — T3/T4).</summary>
|
||||
/// <param name="dt">The not-yet-implemented wide type.</param>
|
||||
[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<NotSupportedException>(() => S7Driver.DecodeScalarBlock(tag, Addr(), block));
|
||||
}
|
||||
|
||||
/// <summary>Verifies String/DateTime encode is a deferred stub (NotSupportedException — T3/T4).</summary>
|
||||
/// <param name="dt">The not-yet-implemented wide type.</param>
|
||||
[Theory]
|
||||
[InlineData(S7DataType.String)]
|
||||
[InlineData(S7DataType.DateTime)]
|
||||
public void EncodeScalarBlock_String_or_DateTime_throws_NotSupported(S7DataType dt)
|
||||
=> Should.Throw<NotSupportedException>(() => S7Driver.EncodeScalarBlock(Tag(dt), "x"));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared in-memory <see cref="IAddressSpaceBuilder"/> recorder used across the S7 driver
|
||||
/// test suite. Captures every folder + variable the driver projects via
|
||||
/// <see cref="S7Driver.DiscoverAsync"/> so a test can assert the discovered shape without a
|
||||
/// live address space. Replaces the three structurally-identical per-file recorders
|
||||
/// (<c>RecordingAddressSpaceBuilder</c>/<c>RecordingBuilder</c>/<c>CapturingBuilder</c>) that
|
||||
/// previously duplicated this code.
|
||||
/// </summary>
|
||||
internal sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>Browse names of every folder the driver created, in order.</summary>
|
||||
public readonly List<string> Folders = new();
|
||||
|
||||
/// <summary>Every variable the driver created, as (browse name, attribute info) pairs.</summary>
|
||||
public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
|
||||
|
||||
/// <summary>Records the folder browse name and returns this builder for chaining.</summary>
|
||||
/// <param name="browseName">The browse name of the folder.</param>
|
||||
/// <param name="displayName">The display name of the folder.</param>
|
||||
/// <returns>This builder instance for method chaining.</returns>
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add(browseName);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Records the variable's browse name + attribute info.</summary>
|
||||
/// <param name="browseName">The browse name of the variable.</param>
|
||||
/// <param name="displayName">The display name of the variable.</param>
|
||||
/// <param name="attributeInfo">The attribute information for the variable.</param>
|
||||
/// <returns>A stub handle.</returns>
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add((browseName, attributeInfo));
|
||||
return new StubHandle();
|
||||
}
|
||||
|
||||
/// <summary>No-op property sink — the S7 driver does not add properties.</summary>
|
||||
/// <param name="browseName">The browse name of the property.</param>
|
||||
/// <param name="dataType">The data type of the property.</param>
|
||||
/// <param name="value">The initial value of the property.</param>
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
/// <summary>No-op alarm sink — the S7 driver never surfaces alarms.</summary>
|
||||
/// <param name="sourceVariable">The variable to attach the alarm to.</param>
|
||||
/// <param name="alarmName">The name of the alarm.</param>
|
||||
/// <param name="alarmInfo">The alarm information.</param>
|
||||
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
/// <summary>Gets the full reference of the variable.</summary>
|
||||
public string FullReference => "stub";
|
||||
|
||||
/// <summary>Marks this variable as an alarm condition.</summary>
|
||||
/// <param name="info">The alarm condition information.</param>
|
||||
/// <returns>An alarm condition sink.</returns>
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
=> throw new NotImplementedException("S7 driver never calls this — no alarm surfacing");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
/// <summary>Records the folder and returns this builder for chaining.</summary>
|
||||
/// <param name="browseName">The browse name of the folder.</param>
|
||||
/// <param name="displayName">The display name of the folder.</param>
|
||||
/// <returns>This builder instance.</returns>
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
/// <summary>Records the variable's name + attribute info.</summary>
|
||||
/// <param name="browseName">The browse name of the variable.</param>
|
||||
/// <param name="displayName">The display name of the variable.</param>
|
||||
/// <param name="attributeInfo">The attribute information for the variable.</param>
|
||||
/// <returns>A stub handle.</returns>
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add((browseName, attributeInfo));
|
||||
return new StubHandle();
|
||||
}
|
||||
|
||||
/// <summary>No-op property sink.</summary>
|
||||
/// <param name="browseName">The browse name of the property.</param>
|
||||
/// <param name="dataType">The data type of the property.</param>
|
||||
/// <param name="value">The initial value of the property.</param>
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
/// <summary>Gets the full reference of the variable.</summary>
|
||||
public string FullReference => "stub";
|
||||
|
||||
/// <summary>Marks this variable as an alarm condition.</summary>
|
||||
/// <param name="info">The alarm condition information.</param>
|
||||
/// <returns>An alarm condition sink.</returns>
|
||||
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.
|
||||
|
||||
/// <summary>Verifies that an Int64 tag discovers a node with DriverDataType.Int64 (no longer lossily mapped to Int32).</summary>
|
||||
[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
|
||||
|
||||
Reference in New Issue
Block a user