Auto: s7-b1 — multi-variable PDU packing
Replaces the per-tag Plc.ReadAsync loop in S7Driver.ReadAsync with a batched ReadMultipleVarsAsync path. Scalar fixed-width tags (Bool, Byte, Int16/UInt16, Int32/UInt32, Float32, Float64) are bin-packed into ≤18-item batches at the default 240-byte PDU using S7.Net.Types.DataItem; arrays, strings, dates, 64-bit ints, and UDT-shaped types stay on the legacy ReadOneAsync path. On batch-level failure each tag in the batch falls back to ReadOneAsync so good tags still produce values and the offender gets its per-item StatusCode (BadDeviceFailure / BadCommunicationError). 100 scalar reads now coalesce into ≤6 PDU round-trips instead of 100. Closes #292
This commit is contained in:
172
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ReadPackerTests.cs
Normal file
172
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ReadPackerTests.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using S7.Net;
|
||||
using S7.Net.Types;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the multi-variable PDU packer (PR-S7-B1). Exercises the static
|
||||
/// packer surface — bin packing, packability classification, DataItem construction,
|
||||
/// and value decode — without needing a live PLC. The wire-level round-trip is
|
||||
/// covered indirectly via the existing single-tag tests; this file pins the
|
||||
/// coalescing math so 100 scalar reads land in ⌈100/19⌉ = 6 PDUs at 240-byte PDU.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7ReadPackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ItemBudget_at_default_240_byte_pdu_is_18()
|
||||
{
|
||||
// 240-byte PDU response: floor((240 - 18) / 12) = 18. The Siemens spec puts
|
||||
// the practical Step-7 ceiling at 19, but the math from the budget formula
|
||||
// (response = negotiatedPduSize − 18 − 12·N ≥ 0) gives 18 — stay with the
|
||||
// honest math, leaving one item of slack for the per-PDU response framing.
|
||||
S7ReadPacker.ItemBudget(240).ShouldBe(18);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItemBudget_at_960_byte_pdu_caps_at_practical_max()
|
||||
{
|
||||
// Even though a 960-byte PDU could mathematically fit ~78 items, many CPU
|
||||
// firmwares reject >20 multi-var items regardless of PDU size. The packer
|
||||
// caps at MaxItemsPerPdu240 = 19 to stay safe under that ceiling.
|
||||
S7ReadPacker.ItemBudget(960).ShouldBe(S7ReadPacker.MaxItemsPerPdu240);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItemBudget_with_tiny_pdu_returns_at_least_one()
|
||||
{
|
||||
// Pathological negotiated PDU smaller than the response header should still
|
||||
// yield a budget of 1 so the driver doesn't divide-by-zero or stall.
|
||||
S7ReadPacker.ItemBudget(8).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BinPack_packs_100_items_into_six_batches_at_default_pdu()
|
||||
{
|
||||
// The headline coalescing claim: 100 scalar reads coalesce into ≤6 PDU calls
|
||||
// at the default 240-byte PDU (5 × 18 + 10 = 100). The previous per-tag loop
|
||||
// would issue 100 round-trips for the same input.
|
||||
var indices = Enumerable.Range(0, 100).ToList();
|
||||
var budget = S7ReadPacker.ItemBudget(240);
|
||||
var batches = S7ReadPacker.BinPack(indices, budget);
|
||||
|
||||
batches.Count.ShouldBeLessThanOrEqualTo(6);
|
||||
batches.ShouldAllBe(b => b.Count <= budget);
|
||||
batches.SelectMany(b => b).ShouldBe(indices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BinPack_preserves_input_order_within_batches()
|
||||
{
|
||||
// The ReadAsync result array indexes by the original caller's order; bin
|
||||
// packing must therefore preserve order so DataItem[k] in the response maps
|
||||
// back to batch[k] which maps back to caller-index.
|
||||
var indices = new List<int> { 7, 3, 11, 1, 5, 13, 9 };
|
||||
var batches = S7ReadPacker.BinPack(indices, itemBudget: 3);
|
||||
|
||||
batches.Count.ShouldBe(3);
|
||||
batches[0].ShouldBe(new[] { 7, 3, 11 });
|
||||
batches[1].ShouldBe(new[] { 1, 5, 13 });
|
||||
batches[2].ShouldBe(new[] { 9 });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7DataType.Bool, S7Size.Bit, true)]
|
||||
[InlineData(S7DataType.Byte, S7Size.Byte, true)]
|
||||
[InlineData(S7DataType.Int16, S7Size.Word, true)]
|
||||
[InlineData(S7DataType.UInt16, S7Size.Word, true)]
|
||||
[InlineData(S7DataType.Int32, S7Size.DWord, true)]
|
||||
[InlineData(S7DataType.UInt32, S7Size.DWord, true)]
|
||||
[InlineData(S7DataType.Float32, S7Size.DWord, true)]
|
||||
[InlineData(S7DataType.Float64, S7Size.LWord, true)]
|
||||
// Types with no native VarType in S7.Net's multi-var path — must fall back.
|
||||
[InlineData(S7DataType.Int64, S7Size.LWord, false)]
|
||||
[InlineData(S7DataType.UInt64, S7Size.LWord, false)]
|
||||
[InlineData(S7DataType.String, S7Size.Byte, false)]
|
||||
[InlineData(S7DataType.WString, S7Size.Byte, false)]
|
||||
[InlineData(S7DataType.Char, S7Size.Byte, false)]
|
||||
[InlineData(S7DataType.Dtl, S7Size.Byte, false)]
|
||||
[InlineData(S7DataType.Date, S7Size.Word, false)]
|
||||
[InlineData(S7DataType.Time, S7Size.DWord, false)]
|
||||
public void IsPackable_classifies_known_scalar_types(S7DataType type, S7Size size, bool expected)
|
||||
{
|
||||
var tag = new S7TagDefinition("t", "DB1.DBW0", type);
|
||||
var addr = new S7ParsedAddress(S7Area.DataBlock, DbNumber: 1, size, ByteOffset: 0, BitOffset: 0);
|
||||
S7ReadPacker.IsPackable(tag, addr).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPackable_rejects_arrays_regardless_of_element_type()
|
||||
{
|
||||
// 1-D arrays go through the byte-range path (PR-S7-A4) — the multi-var
|
||||
// surface can't request "N×elementBytes from offset" as a single DataItem.
|
||||
var tag = new S7TagDefinition("a", "DB1.DBW0", S7DataType.Int16, ElementCount: 4);
|
||||
var addr = new S7ParsedAddress(S7Area.DataBlock, 1, S7Size.Word, 0, 0);
|
||||
S7ReadPacker.IsPackable(tag, addr).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDataItem_for_DB_word_uses_DataBlock_area_and_Word_VarType()
|
||||
{
|
||||
var tag = new S7TagDefinition("t", "DB7.DBW10", S7DataType.UInt16);
|
||||
var addr = new S7ParsedAddress(S7Area.DataBlock, DbNumber: 7, S7Size.Word, ByteOffset: 10, BitOffset: 0);
|
||||
var item = S7ReadPacker.BuildDataItem(tag, addr);
|
||||
|
||||
item.DataType.ShouldBe(DataType.DataBlock);
|
||||
item.VarType.ShouldBe(VarType.Word);
|
||||
item.DB.ShouldBe(7);
|
||||
item.StartByteAdr.ShouldBe(10);
|
||||
item.BitAdr.ShouldBe((byte)0);
|
||||
item.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDataItem_for_bit_address_carries_BitAdr()
|
||||
{
|
||||
var tag = new S7TagDefinition("t", "M0.3", S7DataType.Bool);
|
||||
var addr = new S7ParsedAddress(S7Area.Memory, DbNumber: 0, S7Size.Bit, ByteOffset: 0, BitOffset: 3);
|
||||
var item = S7ReadPacker.BuildDataItem(tag, addr);
|
||||
|
||||
item.DataType.ShouldBe(DataType.Memory);
|
||||
item.VarType.ShouldBe(VarType.Bit);
|
||||
item.BitAdr.ShouldBe((byte)3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDataItem_for_lreal_uses_LReal_VarType()
|
||||
{
|
||||
var tag = new S7TagDefinition("t", "DB1.DBLD0", S7DataType.Float64);
|
||||
var addr = new S7ParsedAddress(S7Area.DataBlock, 1, S7Size.LWord, 0, 0);
|
||||
var item = S7ReadPacker.BuildDataItem(tag, addr);
|
||||
item.VarType.ShouldBe(VarType.LReal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodePackedValue_reinterprets_Word_as_Int16_with_sign()
|
||||
{
|
||||
// S7.Net surfaces Word as ushort; tags declared Int16 reinterpret the bit
|
||||
// pattern as a signed short — the same reinterpret the single-tag ReadOneAsync
|
||||
// path applies, so packed and per-tag results match for the same wire bytes.
|
||||
var tag = new S7TagDefinition("t", "DB1.DBW0", S7DataType.Int16);
|
||||
var decoded = S7ReadPacker.DecodePackedValue(tag, (ushort)0xFFFF);
|
||||
decoded.ShouldBe((short)-1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodePackedValue_reinterprets_DWord_as_Int32_with_sign()
|
||||
{
|
||||
var tag = new S7TagDefinition("t", "DB1.DBD0", S7DataType.Int32);
|
||||
var decoded = S7ReadPacker.DecodePackedValue(tag, 0x80000000u);
|
||||
decoded.ShouldBe(int.MinValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodePackedValue_passes_through_native_Real_double()
|
||||
{
|
||||
var tag = new S7TagDefinition("t", "DB1.DBD0", S7DataType.Float32);
|
||||
var decoded = S7ReadPacker.DecodePackedValue(tag, 1.5f);
|
||||
decoded.ShouldBe(1.5f);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user