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:
@@ -205,6 +205,13 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Phase 1: classify each request into (a) unknown / not-found, (b) packable
|
||||
// scalar (Bool/Byte/Int16/UInt16/Int32/UInt32/Float32/Float64), or (c) needs
|
||||
// per-tag fallback (arrays, strings, dates, 64-bit ints, UDT-fanout). Packable
|
||||
// tags collect into 19-item batches sent via Plc.ReadMultipleVarsAsync; the
|
||||
// rest stay on the legacy ReadOneAsync path.
|
||||
var packableIndexes = new List<int>(fullReferences.Count);
|
||||
var fallbackIndexes = new List<int>();
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var name = fullReferences[i];
|
||||
@@ -213,36 +220,135 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
var addr = _parsedByName[name];
|
||||
if (S7ReadPacker.IsPackable(tag, addr)) packableIndexes.Add(i);
|
||||
else fallbackIndexes.Add(i);
|
||||
}
|
||||
|
||||
// Phase 2: bin-pack and dispatch the packable group via ReadMultipleVarsAsync.
|
||||
// On a per-batch S7.Net failure the whole batch falls back to ReadOneAsync per
|
||||
// tag — that way one bad item doesn't poison the rest of the batch and each
|
||||
// tag still gets its own per-item StatusCode (BadDeviceFailure for PUT/GET
|
||||
// refusal, BadCommunicationError for transport faults).
|
||||
if (packableIndexes.Count > 0)
|
||||
{
|
||||
var budget = S7ReadPacker.ItemBudget(S7ReadPacker.DefaultPduSize);
|
||||
var batches = S7ReadPacker.BinPack(packableIndexes, budget);
|
||||
foreach (var batch in batches)
|
||||
{
|
||||
var value = await ReadOneAsync(plc, tag, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||
}
|
||||
catch (global::S7.Net.PlcException pex)
|
||||
{
|
||||
// S7.Net's PlcException carries an ErrorCode; PUT/GET-disabled on
|
||||
// S7-1200/1500 surfaces here. Map to BadDeviceFailure so operators see a
|
||||
// device-config problem (toggle PUT/GET in TIA Portal) rather than a
|
||||
// transient fault — per driver-specs.md §5.
|
||||
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
await ReadBatchAsync(plc, batch, fullReferences, results, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: per-tag fallback for everything that can't pack into a single
|
||||
// DataItem. Keeps the existing decode path as the source of truth for
|
||||
// string/date/array/64-bit semantics.
|
||||
foreach (var i in fallbackIndexes)
|
||||
{
|
||||
var tag = _tagsByName[fullReferences[i]];
|
||||
results[i] = await ReadOneAsSnapshotAsync(plc, tag, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read one packed batch via <c>Plc.ReadMultipleVarsAsync</c>. On batch
|
||||
/// success each <c>DataItem.Value</c> decodes into its tag's snapshot
|
||||
/// slot; on batch failure each tag in the batch falls back to
|
||||
/// <see cref="ReadOneAsSnapshotAsync"/> so the failure fans out per-tag instead
|
||||
/// of poisoning the whole batch with one StatusCode.
|
||||
/// </summary>
|
||||
private async Task ReadBatchAsync(
|
||||
global::S7.Net.Plc plc,
|
||||
IReadOnlyList<int> batchIndexes,
|
||||
IReadOnlyList<string> fullReferences,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime now,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var items = new List<global::S7.Net.Types.DataItem>(batchIndexes.Count);
|
||||
foreach (var idx in batchIndexes)
|
||||
{
|
||||
var name = fullReferences[idx];
|
||||
items.Add(S7ReadPacker.BuildDataItem(_tagsByName[name], _parsedByName[name]));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var responses = await plc.ReadMultipleVarsAsync(items, ct).ConfigureAwait(false);
|
||||
// S7.Net mutates the input list in place and also returns it; iterate by
|
||||
// index against the input list so we are agnostic to either contract.
|
||||
for (var k = 0; k < batchIndexes.Count; k++)
|
||||
{
|
||||
var idx = batchIndexes[k];
|
||||
var tag = _tagsByName[fullReferences[idx]];
|
||||
var raw = (responses != null && k < responses.Count ? responses[k] : items[k]).Value;
|
||||
if (raw is null)
|
||||
{
|
||||
results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
var decoded = S7ReadPacker.DecodePackedValue(tag, raw);
|
||||
results[idx] = new DataValueSnapshot(decoded, 0u, now, now);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[idx] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Batch-level fault: most likely a single bad address poisoned the
|
||||
// multi-var response. Fall back to ReadOneAsync per tag in the batch so
|
||||
// good tags still surface a value and the offender gets its own StatusCode.
|
||||
foreach (var idx in batchIndexes)
|
||||
{
|
||||
var tag = _tagsByName[fullReferences[idx]];
|
||||
results[idx] = await ReadOneAsSnapshotAsync(plc, tag, now, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-tag read wrapped as a <see cref="DataValueSnapshot"/> with the same
|
||||
/// exception-to-StatusCode mapping the legacy per-tag loop applied. Shared
|
||||
/// between the fallback path and the post-batch retry path so the failure
|
||||
/// surface stays identical.
|
||||
/// </summary>
|
||||
private async Task<DataValueSnapshot> ReadOneAsSnapshotAsync(
|
||||
global::S7.Net.Plc plc, S7TagDefinition tag, DateTime now, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = await ReadOneAsync(plc, tag, ct).ConfigureAwait(false);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
return new DataValueSnapshot(value, 0u, now, now);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||
}
|
||||
catch (global::S7.Net.PlcException pex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||
return new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
return new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
|
||||
190
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadPacker.cs
Normal file
190
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadPacker.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
using S7.Net;
|
||||
using S7.Net.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-variable PDU packer for S7 reads. Replaces the per-tag <c>Plc.ReadAsync</c>
|
||||
/// loop with batched <c>Plc.ReadMultipleVarsAsync</c> calls so that N scalar tags fit
|
||||
/// into ⌈N / 19⌉ PDU round-trips on a default 240-byte negotiated PDU instead of N.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Packing budget</b>: Siemens S7 read response budget is
|
||||
/// <c>negotiatedPduSize - 18 - 12·N</c>, where the 18 bytes cover the response
|
||||
/// header / parameter headers and 12 bytes per item carry the per-variable item
|
||||
/// response (return code + data header + value). For a 240-byte PDU the absolute
|
||||
/// ceiling is ~19 items per request before the response overflows; we apply that
|
||||
/// as a conservative cap regardless of negotiated PDU since S7.Net does not
|
||||
/// expose the negotiated size and 240 is the default for every CPU family.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Packable types only</b>: only fixed-width scalars where the wire layout
|
||||
/// maps 1-to-1 onto an <see cref="VarType"/> the multi-var path natively decodes
|
||||
/// (Bool, Byte, Int16/UInt16, Int32/UInt32, Float32, Float64). Strings, dates,
|
||||
/// arrays, 64-bit ints, and UDT-shaped types stay on the per-tag
|
||||
/// <c>ReadOneAsync</c> path because their decode requires
|
||||
/// <c>Plc.ReadBytesAsync</c> + bespoke codec rather than a single
|
||||
/// <see cref="DataItem"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class S7ReadPacker
|
||||
{
|
||||
/// <summary>
|
||||
/// Default negotiated S7 PDU size (bytes). Every S7 CPU family negotiates 240 by
|
||||
/// default; the extended-PDU 480 / 960 byte settings need an explicit COTP
|
||||
/// parameter that S7.Net does not expose. Stay conservative.
|
||||
/// </summary>
|
||||
internal const int DefaultPduSize = 240;
|
||||
|
||||
/// <summary>
|
||||
/// Per-item response overhead in bytes — return code + data type code + length
|
||||
/// field. The S7 spec calls this 4 bytes minimum but rounds up to 12 once the
|
||||
/// payload alignment + worst-case 8-byte LReal value field are included.
|
||||
/// </summary>
|
||||
internal const int PerItemResponseBytes = 12;
|
||||
|
||||
/// <summary>Fixed response-header bytes regardless of item count.</summary>
|
||||
internal const int ResponseHeaderBytes = 18;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum items per PDU at the default 240-byte negotiated size. Derived from
|
||||
/// <c>floor((240 - 18) / 12) = 18.5</c> rounded down to 18 plus 1 for a
|
||||
/// response-header slack the S7 spec rounds up; the practical Siemens limit
|
||||
/// documented in TIA Portal is 19 items per <c>PUT</c>/<c>GET</c> call so we cap
|
||||
/// at 19 and rely on the budget calculation only when a non-default PDU is in
|
||||
/// play.
|
||||
/// </summary>
|
||||
internal const int MaxItemsPerPdu240 = 19;
|
||||
|
||||
/// <summary>
|
||||
/// Compute how many items can fit in one <c>Plc.ReadMultipleVarsAsync</c>
|
||||
/// call at the given negotiated PDU size, capped at the practical Siemens
|
||||
/// ceiling of 19 items.
|
||||
/// </summary>
|
||||
internal static int ItemBudget(int negotiatedPduSize)
|
||||
{
|
||||
if (negotiatedPduSize <= ResponseHeaderBytes + PerItemResponseBytes)
|
||||
return 1;
|
||||
var byBudget = (negotiatedPduSize - ResponseHeaderBytes) / PerItemResponseBytes;
|
||||
return Math.Min(byBudget, MaxItemsPerPdu240);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if the tag can be packed into a single <see cref="DataItem"/> for
|
||||
/// <c>Plc.ReadMultipleVarsAsync</c>. Returns false for everything that
|
||||
/// needs a custom byte-range decode (strings, dates, arrays, UDTs, 64-bit ints
|
||||
/// where S7.Net's <see cref="VarType"/> has no entry).
|
||||
/// </summary>
|
||||
internal static bool IsPackable(S7TagDefinition tag, S7ParsedAddress addr)
|
||||
{
|
||||
if (tag.ElementCount is int n && n > 1) return false; // arrays go through ReadOneAsync
|
||||
return tag.DataType switch
|
||||
{
|
||||
S7DataType.Bool when addr.Size == S7Size.Bit => true,
|
||||
S7DataType.Byte when addr.Size == S7Size.Byte => true,
|
||||
S7DataType.Int16 or S7DataType.UInt16 when addr.Size == S7Size.Word => true,
|
||||
S7DataType.Int32 or S7DataType.UInt32 when addr.Size == S7Size.DWord => true,
|
||||
S7DataType.Float32 when addr.Size == S7Size.DWord => true,
|
||||
S7DataType.Float64 when addr.Size == S7Size.LWord => true,
|
||||
// Int64 / UInt64 have no native VarType; S7.Net's multi-var path can't decode
|
||||
// them without falling back to byte-range reads. Route to ReadOneAsync.
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="DataItem"/> for a packable tag. <see cref="VarType"/> is
|
||||
/// chosen so that S7.Net's multi-var path decodes the wire bytes into a .NET type
|
||||
/// this driver can reinterpret without a second PLC round-trip
|
||||
/// (Word→ushort, DWord→uint, etc.).
|
||||
/// </summary>
|
||||
internal static DataItem BuildDataItem(S7TagDefinition tag, S7ParsedAddress addr)
|
||||
{
|
||||
var dataType = MapArea(addr.Area);
|
||||
var varType = tag.DataType switch
|
||||
{
|
||||
S7DataType.Bool => VarType.Bit,
|
||||
S7DataType.Byte => VarType.Byte,
|
||||
// Int16 read via Word (UInt16 wire) and reinterpreted to short in
|
||||
// DecodePackedValue; gives identical wire behaviour to the single-tag path.
|
||||
S7DataType.Int16 => VarType.Word,
|
||||
S7DataType.UInt16 => VarType.Word,
|
||||
S7DataType.Int32 => VarType.DWord,
|
||||
S7DataType.UInt32 => VarType.DWord,
|
||||
S7DataType.Float32 => VarType.Real,
|
||||
S7DataType.Float64 => VarType.LReal,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"S7ReadPacker: tag '{tag.Name}' DataType {tag.DataType} is not packable; IsPackable check skipped"),
|
||||
};
|
||||
return new DataItem
|
||||
{
|
||||
DataType = dataType,
|
||||
VarType = varType,
|
||||
DB = addr.DbNumber,
|
||||
StartByteAdr = addr.ByteOffset,
|
||||
BitAdr = (byte)addr.BitOffset,
|
||||
Count = 1,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert the boxed value S7.Net's multi-var path returns into the .NET type
|
||||
/// declared by <paramref name="tag"/>. Mirrors the reinterpret table in
|
||||
/// <c>S7Driver.ReadOneAsync</c> so packed reads and single-tag reads produce
|
||||
/// identical snapshots for the same input.
|
||||
/// </summary>
|
||||
internal static object DecodePackedValue(S7TagDefinition tag, object raw)
|
||||
{
|
||||
return (tag.DataType, raw) switch
|
||||
{
|
||||
(S7DataType.Bool, bool b) => b,
|
||||
(S7DataType.Byte, byte by) => by,
|
||||
(S7DataType.UInt16, ushort u16) => u16,
|
||||
(S7DataType.Int16, ushort u16) => unchecked((short)u16),
|
||||
(S7DataType.UInt32, uint u32) => u32,
|
||||
(S7DataType.Int32, uint u32) => unchecked((int)u32),
|
||||
(S7DataType.Float32, float f) => f,
|
||||
(S7DataType.Float64, double d) => d,
|
||||
// S7.Net occasionally hands back the underlying integer type for Real/LReal
|
||||
// when the bytes were marshalled raw — reinterpret defensively.
|
||||
(S7DataType.Float32, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
||||
(S7DataType.Float64, ulong u64) => BitConverter.UInt64BitsToDouble(u64),
|
||||
_ => throw new System.IO.InvalidDataException(
|
||||
$"S7ReadPacker: tag '{tag.Name}' declared {tag.DataType} but multi-var returned {raw.GetType().Name}"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bin-pack <paramref name="indices"/> into batches of at most
|
||||
/// <paramref name="itemBudget"/> items. Order within each batch matches the
|
||||
/// input order so the per-item response from S7.Net maps back 1-to-1.
|
||||
/// </summary>
|
||||
internal static List<List<int>> BinPack(IReadOnlyList<int> indices, int itemBudget)
|
||||
{
|
||||
var batches = new List<List<int>>();
|
||||
var current = new List<int>(itemBudget);
|
||||
foreach (var idx in indices)
|
||||
{
|
||||
current.Add(idx);
|
||||
if (current.Count >= itemBudget)
|
||||
{
|
||||
batches.Add(current);
|
||||
current = new List<int>(itemBudget);
|
||||
}
|
||||
}
|
||||
if (current.Count > 0) batches.Add(current);
|
||||
return batches;
|
||||
}
|
||||
|
||||
private static DataType MapArea(S7Area area) => area switch
|
||||
{
|
||||
S7Area.DataBlock => DataType.DataBlock,
|
||||
S7Area.Memory => DataType.Memory,
|
||||
S7Area.Input => DataType.Input,
|
||||
S7Area.Output => DataType.Output,
|
||||
S7Area.Timer => DataType.Timer,
|
||||
S7Area.Counter => DataType.Counter,
|
||||
_ => throw new InvalidOperationException($"Unknown S7Area {area}"),
|
||||
};
|
||||
}
|
||||
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