Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ReadPackerTests.cs
Joseph Doherty d7633fe36f 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
2026-04-25 21:04:32 -04:00

173 lines
7.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}