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:
Joseph Doherty
2026-04-25 21:04:32 -04:00
parent 69d9a6fbb5
commit d7633fe36f
3 changed files with 490 additions and 22 deletions

View File

@@ -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];