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
191 lines
8.7 KiB
C#
191 lines
8.7 KiB
C#
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}"),
|
|
};
|
|
}
|