Sum-read (#310): batches a poll's reads per device into sum commands capped at sumReadMaxItems, per-item ADS errors mapped per tag, falls back to individual reads when the target refuses the command. Diagnostics (#314): ADS state, app name, online-change count, task cycle/exec/jitter via IDriverDiagnostics. Re-implemented against the current raw-byte read path (stranded auto/driver-gaps commits predate it). Sum-write not ported. Unit tests only; live gate pending a route. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -61,6 +61,22 @@ public sealed class TwinCATDriverOptions
|
||||
/// </summary>
|
||||
public int NotificationMaxDelayMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, a poll's reads for one device are batched into ADS sum-read commands
|
||||
/// (IndexGroup <c>0xF080</c>) of up to <see cref="SumReadMaxItems"/> symbols each instead of one
|
||||
/// round-trip per tag (Gitea #645 / #310). Default <c>false</c> (opt-in): a TwinCAT 2 CE runtime's
|
||||
/// sum-command support is unverified. A target that refuses the sum command is detected at runtime
|
||||
/// and that device falls back to individual reads for the rest of the driver's life.
|
||||
/// </summary>
|
||||
public bool UseSumRead { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum symbols per ADS sum-read command; larger polls are split into chunks of this size.
|
||||
/// Bounds the request/response size against the target's ADS buffer. Values below 1 are treated as 1.
|
||||
/// Only consulted when <see cref="UseSumRead"/> is <c>true</c>.
|
||||
/// </summary>
|
||||
public int SumReadMaxItems { get; init; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using TwinCAT;
|
||||
using TwinCAT.Ads;
|
||||
using TwinCAT.Ads.SumCommand;
|
||||
using TwinCAT.Ads.TypeSystem;
|
||||
using TwinCAT.TypeSystem;
|
||||
|
||||
@@ -166,6 +167,151 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
|
||||
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new (object? value, uint status)[reads.Count];
|
||||
var planned = new List<PlannedSumRead>(reads.Count);
|
||||
|
||||
// Plan every item exactly as the single read would (symbol resolved from the Beckhoff symbol cache,
|
||||
// declared type checked against it), but stop short of the wire read: those become ONE sum command.
|
||||
// Anything the sum command cannot carry is read on its own right here.
|
||||
for (var i = 0; i < reads.Count; i++)
|
||||
{
|
||||
var r = reads[i];
|
||||
try
|
||||
{
|
||||
if (r.BitIndex is not null && r.Type == TwinCATDataType.Bool)
|
||||
{
|
||||
results[i] = await ReadValueAsync(r.SymbolPath, r.Type, r.BitIndex, r.ArrayCount, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(r.SymbolPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (symbolStatus != TwinCATStatusMapper.Good) { results[i] = (null, symbolStatus); continue; }
|
||||
|
||||
TwinCATArrayLayout layout;
|
||||
if (r.ArrayCount is int count)
|
||||
{
|
||||
if (TwinCATTypeGuard.CheckArray(r.Type, count, shape, array, out layout) is not null)
|
||||
{
|
||||
results[i] = (null, TwinCATStatusMapper.BadTypeMismatch);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (TwinCATTypeGuard.CheckRead(r.Type, shape) is not null)
|
||||
{
|
||||
results[i] = (null, TwinCATStatusMapper.BadTypeMismatch);
|
||||
continue;
|
||||
}
|
||||
// A scalar is a one-element "array" at the type's own width (a STRING at the PLC's declared
|
||||
// width), so scalar and array share one raw decode.
|
||||
layout = new TwinCATArrayLayout(1, r.Type is TwinCATDataType.String or TwinCATDataType.WString
|
||||
? shape.ByteSize
|
||||
: TwinCATTypeGuard.DeclaredByteSize(r.Type));
|
||||
}
|
||||
|
||||
// A handle-addressed symbol (0xF005) needs a fresh variable handle per read — not expressible
|
||||
// as an index-group/offset sum entry. Read it individually.
|
||||
if (symbol!.IndexGroup == SymbolValueByHandle)
|
||||
{
|
||||
results[i] = await ReadValueAsync(r.SymbolPath, r.Type, r.BitIndex, r.ArrayCount, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
planned.Add(new PlannedSumRead(i, symbol.IndexGroup, symbol.IndexOffset, layout, r.ArrayCount is not null));
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
results[i] = (null, MapAndSignal((uint)ex.ErrorCode));
|
||||
}
|
||||
}
|
||||
|
||||
if (planned.Count == 0) return results;
|
||||
|
||||
ResultSumReadRaw sum;
|
||||
try
|
||||
{
|
||||
var entities = new List<IgIoSumReadEntity>(planned.Count);
|
||||
foreach (var p in planned)
|
||||
entities.Add(new IgIoSumReadEntity(p.IndexGroup, p.IndexOffset, p.Layout.ByteLength));
|
||||
sum = await new SumRead(_client, entities, SumCommandErrorStrategy.Relaxed)
|
||||
.ReadRawAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AdsErrorException ex) when (IsSumRefusal((uint)ex.ErrorCode))
|
||||
{
|
||||
throw new TwinCATSumReadRefusedException((uint)ex.ErrorCode, ex);
|
||||
}
|
||||
catch (AdsSumCommandException)
|
||||
{
|
||||
// The library gave up on the batch without an overall ADS code (a sub-item failure under its
|
||||
// strict handling). Not a refusal of the service: read these items one by one this time.
|
||||
foreach (var p in planned)
|
||||
results[p.Index] = await ReadValueAsync(reads[p.Index].SymbolPath, reads[p.Index].Type,
|
||||
reads[p.Index].BitIndex, reads[p.Index].ArrayCount, cancellationToken).ConfigureAwait(false);
|
||||
return results;
|
||||
}
|
||||
|
||||
// The overall code is the sum command itself; per-item codes are in SubErrors.
|
||||
if (sum.ErrorCode != AdsErrorCode.NoError)
|
||||
{
|
||||
var overall = (uint)sum.ErrorCode;
|
||||
if (IsSumRefusal(overall))
|
||||
throw new TwinCATSumReadRefusedException(overall);
|
||||
var mapped = MapAndSignal(overall);
|
||||
foreach (var p in planned) results[p.Index] = (null, mapped);
|
||||
return results;
|
||||
}
|
||||
|
||||
for (var k = 0; k < planned.Count; k++)
|
||||
{
|
||||
var p = planned[k];
|
||||
var subError = sum.SubErrors is { } errs && k < errs.Length ? errs[k] : AdsErrorCode.NoError;
|
||||
if (subError != AdsErrorCode.NoError)
|
||||
{
|
||||
results[p.Index] = (null, MapAndSignal((uint)subError));
|
||||
continue;
|
||||
}
|
||||
if (sum.ReadBlocks is not { } blocks || k >= blocks.Count || blocks[k].Length < p.Layout.ByteLength)
|
||||
{
|
||||
// A short block would decode garbage; treat it as a size mismatch, not a value.
|
||||
results[p.Index] = (null, TwinCATStatusMapper.MapAdsError(1797));
|
||||
continue;
|
||||
}
|
||||
var decoded = TwinCATArrayCodec.Decode(reads[p.Index].Type, sum.ReadBlocks![k].Span, p.Layout, StringsAreUtf8());
|
||||
results[p.Index] = (p.IsArray ? decoded : decoded.GetValue(0), TwinCATStatusMapper.Good);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private const uint SymbolValueByHandle = 0xF005;
|
||||
|
||||
private readonly record struct PlannedSumRead(int Index, uint IndexGroup, uint IndexOffset, TwinCATArrayLayout Layout, bool IsArray);
|
||||
|
||||
/// <summary>
|
||||
/// True for an overall ADS code that means "this target does not do sum commands" (service / index
|
||||
/// group / offset / size not supported) rather than a comms fault.
|
||||
/// </summary>
|
||||
internal static bool IsSumRefusal(uint adsError) => adsError is 1792 or 1793 or 1794 or 1795 or 1797 or 1798;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> ReadAdsStateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await _client.ReadStateAsync(cancellationToken).ConfigureAwait(false);
|
||||
return state.ErrorCode == AdsErrorCode.NoError ? state.State.AdsState.ToString() : null;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the PLC symbol behind <paramref name="symbolPath"/> into the facts
|
||||
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
|
||||
|
||||
@@ -55,6 +55,45 @@ public interface ITwinCATClient : IDisposable
|
||||
int? arrayCount,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Reads several symbols at once — the ADS sum-read path (Gitea #645 / #310). The result is a parallel
|
||||
/// list: element <c>i</c> is the outcome of <paramref name="reads"/>[<c>i</c>], each decoded and
|
||||
/// status-mapped exactly as <see cref="ReadValueAsync"/> would (an item the sum command cannot carry —
|
||||
/// a bit-indexed BOOL, a handle-addressed symbol — is read individually inside the call).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation reads one item at a time, so a client without a sum path (or a test
|
||||
/// fake) is correct if not faster. An implementation that issues a real sum command throws
|
||||
/// <see cref="TwinCATSumReadRefusedException"/> when the TARGET refuses the command as a whole
|
||||
/// (service / index group not supported); the caller then falls back to individual reads. A
|
||||
/// comms-level failure propagates as the ordinary exception.
|
||||
/// </remarks>
|
||||
/// <param name="reads">The symbols to read, at most the caller's chunk size.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the read.</param>
|
||||
/// <returns>One <c>(value, status)</c> per item, in request order.</returns>
|
||||
/// <exception cref="TwinCATSumReadRefusedException">The target refused the sum command itself.</exception>
|
||||
async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
|
||||
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new (object? value, uint status)[reads.Count];
|
||||
for (var i = 0; i < reads.Count; i++)
|
||||
{
|
||||
var r = reads[i];
|
||||
results[i] = await ReadValueAsync(r.SymbolPath, r.Type, r.BitIndex, r.ArrayCount, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The target's ADS device state (<c>Run</c>, <c>Stop</c>, <c>Config</c>, …) as text, or <c>null</c>
|
||||
/// when it cannot be read. Feeds the driver's <c>/hosts</c> diagnostics (Gitea #645 / #314).
|
||||
/// Default <c>null</c> — a client without a state read simply reports none.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the read.</param>
|
||||
/// <returns>The state name, or <c>null</c>.</returns>
|
||||
Task<string?> ReadAdsStateAsync(CancellationToken cancellationToken) => Task.FromResult<string?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
|
||||
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
|
||||
@@ -132,6 +171,18 @@ public interface ITwinCATClient : IDisposable
|
||||
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>One element of an <see cref="ITwinCATClient.ReadValuesAsync"/> request — the same four facts
|
||||
/// <see cref="ITwinCATClient.ReadValueAsync"/> takes.</summary>
|
||||
/// <param name="SymbolPath">The ADS symbol path (a bit-indexed BOOL keeps its <c>.N</c> suffix, as for a single read).</param>
|
||||
/// <param name="Type">The declared data type.</param>
|
||||
/// <param name="BitIndex">The bit to extract for a BOOL-within-word tag; null otherwise.</param>
|
||||
/// <param name="ArrayCount">The authored array length for an array tag; null for a scalar.</param>
|
||||
public sealed record TwinCATBulkReadItem(
|
||||
string SymbolPath,
|
||||
TwinCATDataType Type,
|
||||
int? BitIndex = null,
|
||||
int? ArrayCount = null);
|
||||
|
||||
/// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary>
|
||||
public interface ITwinCATNotificationHandle : IDisposable { }
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// One device's probe-sampled PLC facts (Gitea #645 / #314), surfaced through the driver's
|
||||
/// <see cref="Core.Abstractions.IDriverDiagnostics"/> bag to the <c>/hosts</c> page. Every field is
|
||||
/// best-effort: a runtime that does not export a system symbol leaves that field at its last known value
|
||||
/// (null if never seen).
|
||||
/// </summary>
|
||||
/// <param name="AdsState">The ADS device state name (<c>Run</c>, <c>Stop</c>, <c>Config</c>, …).</param>
|
||||
/// <param name="AppName">The running PLC application's name (<c>_AppInfo.AppName</c>).</param>
|
||||
/// <param name="OnlineChangeCnt">How many online changes the application has taken (<c>_AppInfo.OnlineChangeCnt</c>).</param>
|
||||
/// <param name="CycleTimeMs">The first PLC task's configured cycle time in milliseconds.</param>
|
||||
/// <param name="LastExecTimeMs">How long the task's last cycle took to execute, in milliseconds.</param>
|
||||
/// <param name="UpdatedUtc">When the snapshot was taken.</param>
|
||||
internal sealed record TwinCATDeviceDiagnostics(
|
||||
string? AdsState,
|
||||
string? AppName,
|
||||
uint? OnlineChangeCnt,
|
||||
double? CycleTimeMs,
|
||||
double? LastExecTimeMs,
|
||||
DateTime UpdatedUtc)
|
||||
{
|
||||
/// <summary>Gets last execution time minus cycle time: positive means the task overran its cycle.</summary>
|
||||
public double? JitterMs => CycleTimeMs is { } c && LastExecTimeMs is { } x ? x - c : null;
|
||||
}
|
||||
@@ -12,7 +12,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
/// resolver land in PRs 2 and 3.
|
||||
/// </summary>
|
||||
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, ISkippedTagReporter, IDriverDiagnostics,
|
||||
IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
@@ -271,6 +272,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
// #645/#310: with UseSumRead the reads for one device are gathered and issued as ADS sum-reads
|
||||
// after this pass; everything else (and a device that refused the sum command) reads one by one.
|
||||
Dictionary<DeviceState, List<SumReadEntry>>? buckets = null;
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
@@ -285,47 +290,135 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_options.UseSumRead && !device.SumReadUnsupported)
|
||||
{
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
buckets ??= [];
|
||||
if (!buckets.TryGetValue(device, out var bucket))
|
||||
buckets[device] = bucket = [];
|
||||
bucket.Add(new SumReadEntry(i, reference, def, symbolName, parsed?.BitIndex));
|
||||
continue;
|
||||
}
|
||||
|
||||
results[i] = await ReadOneAsync(reference, def, device, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (buckets is not null)
|
||||
foreach (var (device, bucket) in buckets)
|
||||
await ReadBucketAsync(device, bucket, results, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private readonly record struct SumReadEntry(
|
||||
int Index, string Reference, TwinCATTagDefinition Def, string SymbolName, int? BitIndex);
|
||||
|
||||
/// <summary>One tag read on its own — the pre-sum-read path, also the fallback for a refused sum command.</summary>
|
||||
private async Task<DataValueSnapshot> ReadOneAsync(
|
||||
string reference, TwinCATTagDefinition def, DeviceState device, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
// An array-typed tag (def.ArrayLength != null) reads the array as raw bytes and decodes
|
||||
// it with TwinCATArrayCodec — the same decode a native array notification runs — into an
|
||||
// element-typed CLR array. Scalar tags pass null (scalar path) — bit-indexed BOOLs are
|
||||
// inherently scalar so the client ignores arrayCount when a bitIndex is present.
|
||||
var (value, status) = await client.ReadValueAsync(
|
||||
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return CompleteRead(reference, def, symbolName, value, status, now);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
return new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps one read outcome to a snapshot and applies the health / log side effects.</summary>
|
||||
private DataValueSnapshot CompleteRead(
|
||||
string reference, TwinCATTagDefinition def, string symbolName, object? value, uint status, DateTime now)
|
||||
{
|
||||
if (status == TwinCATStatusMapper.Good)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
return new DataValueSnapshot(value, status, now, now);
|
||||
}
|
||||
|
||||
if (status == TwinCATStatusMapper.BadTypeMismatch)
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
|
||||
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch, array element type " +
|
||||
"or length mismatch, or a reference/container)",
|
||||
_driverInstanceId, reference, def.DataType, symbolName);
|
||||
else
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'",
|
||||
_driverInstanceId, status, reference);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"ADS status {status:X8} reading {reference}");
|
||||
return new DataValueSnapshot(value, status, now, now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one device's gathered tags as ADS sum-reads of at most <c>SumReadMaxItems</c> symbols each.
|
||||
/// A target that refuses the sum command flips <see cref="DeviceState.SumReadUnsupported"/> and the
|
||||
/// rest of the bucket is read individually; any other failure marks only the affected chunk Bad.
|
||||
/// </summary>
|
||||
private async Task ReadBucketAsync(
|
||||
DeviceState device, List<SumReadEntry> bucket, DataValueSnapshot[] results, DateTime now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var chunkSize = Math.Max(1, _options.SumReadMaxItems);
|
||||
for (var start = 0; start < bucket.Count; start += chunkSize)
|
||||
{
|
||||
var chunk = bucket.GetRange(start, Math.Min(chunkSize, bucket.Count - start));
|
||||
|
||||
if (device.SumReadUnsupported || chunk.Count == 1)
|
||||
{
|
||||
foreach (var e in chunk)
|
||||
results[e.Index] = await ReadOneAsync(e.Reference, e.Def, device, now, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
// An array-typed tag (def.ArrayLength != null) reads the array as raw bytes and decodes
|
||||
// it with TwinCATArrayCodec — the same decode a native array notification runs — into an
|
||||
// element-typed CLR array. Scalar tags pass null (scalar path) — bit-indexed BOOLs are
|
||||
// inherently scalar so the client ignores arrayCount when a bitIndex is present.
|
||||
var (value, status) = await client.ReadValueAsync(
|
||||
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var items = new TwinCATBulkReadItem[chunk.Count];
|
||||
for (var k = 0; k < chunk.Count; k++)
|
||||
items[k] = new TwinCATBulkReadItem(chunk[k].SymbolName, chunk[k].Def.DataType, chunk[k].BitIndex, chunk[k].Def.ArrayLength);
|
||||
|
||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||
if (status == TwinCATStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
else
|
||||
var bulk = await client.ReadValuesAsync(items, cancellationToken).ConfigureAwait(false);
|
||||
for (var k = 0; k < chunk.Count; k++)
|
||||
{
|
||||
if (status == TwinCATStatusMapper.BadTypeMismatch)
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
|
||||
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch, array element type " +
|
||||
"or length mismatch, or a reference/container)",
|
||||
_driverInstanceId, reference, def.DataType, symbolName);
|
||||
else
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'",
|
||||
_driverInstanceId, status, reference);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"ADS status {status:X8} reading {reference}");
|
||||
var e = chunk[k];
|
||||
var (value, status) = k < bulk.Count ? bulk[k] : (null, TwinCATStatusMapper.BadCommunicationError);
|
||||
results[e.Index] = CompleteRead(e.Reference, e.Def, e.SymbolName, value, status, now);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (TwinCATSumReadRefusedException refused)
|
||||
{
|
||||
device.SumReadUnsupported = true;
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' device '{Device}' refused the ADS sum-read command " +
|
||||
"(ADS error {AdsError}); falling back to individual reads for this device",
|
||||
_driverInstanceId, device.Options.HostAddress, refused.AdsErrorCode);
|
||||
foreach (var e in chunk)
|
||||
results[e.Index] = await ReadOneAsync(e.Reference, e.Def, device, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
|
||||
foreach (var e in chunk)
|
||||
results[e.Index] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
@@ -793,7 +886,11 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
else
|
||||
// 05/STAB-16: on a healthy tick, re-register any notifications whose replay
|
||||
// failed (a transient AddNotification blip) so they recover without a client swap.
|
||||
{
|
||||
await RetryDeadRegistrationsAsync(state, ct).ConfigureAwait(false);
|
||||
// #645/#314: sample the PLC state / task-cycle facts for /hosts. Best-effort.
|
||||
await SampleDeviceDiagnosticsAsync(state, client, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch
|
||||
@@ -813,6 +910,114 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IDriverDiagnostics (#645 / #314) ----
|
||||
|
||||
// Well-known system symbols, exported by TC3 (and most TC2) PLC runtimes under TwinCAT_SystemInfoVarList.
|
||||
// _TaskInfo[1] is the first PLC task; CycleTime / LastExecTime are UDINT counts of 100 ns.
|
||||
internal const string SymOnlineChangeCnt = "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt";
|
||||
internal const string SymAppName = "TwinCAT_SystemInfoVarList._AppInfo.AppName";
|
||||
internal const string SymCycleTime = "TwinCAT_SystemInfoVarList._TaskInfo[1].CycleTime";
|
||||
internal const string SymLastExecTime = "TwinCAT_SystemInfoVarList._TaskInfo[1].LastExecTime";
|
||||
|
||||
/// <summary>Converts a TwinCAT 100 ns tick count to milliseconds.</summary>
|
||||
internal static double TicksToMilliseconds(uint ticks100ns) => ticks100ns / 10_000.0;
|
||||
|
||||
/// <summary>
|
||||
/// Most recent <see cref="TwinCATDeviceDiagnostics"/> for a device, or null before its first
|
||||
/// successful probe sample (or for an unknown host address).
|
||||
/// </summary>
|
||||
/// <param name="hostAddress">The device's ADS host address.</param>
|
||||
/// <returns>The snapshot, or null.</returns>
|
||||
internal TwinCATDeviceDiagnostics? GetDeviceDiagnostics(string hostAddress) =>
|
||||
_devices.TryGetValue(hostAddress, out var s) ? s.LastDiagnostics : null;
|
||||
|
||||
/// <summary>
|
||||
/// Samples the ADS device state and the four system symbols once per healthy probe tick and stores the
|
||||
/// result on <see cref="DeviceState.LastDiagnostics"/>. Every read is individually best-effort: a
|
||||
/// runtime that does not export a symbol (older TwinCAT 2 builds) keeps the previous value, and a
|
||||
/// symbol that answers "not found" is not asked again until the client is rebuilt. Never throws
|
||||
/// (except cancellation) — diagnostics must not disturb the probe.
|
||||
/// </summary>
|
||||
internal async Task SampleDeviceDiagnosticsAsync(DeviceState state, ITwinCATClient client, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var prev = state.LastDiagnostics;
|
||||
var adsState = prev?.AdsState;
|
||||
try { adsState = await client.ReadAdsStateAsync(ct).ConfigureAwait(false) ?? adsState; }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch { /* keep previous */ }
|
||||
|
||||
var appName = await ReadDiagSymbolAsync(state, client, SymAppName, TwinCATDataType.String, ct).ConfigureAwait(false) as string
|
||||
?? prev?.AppName;
|
||||
var onlineChange = ToUInt(await ReadDiagSymbolAsync(state, client, SymOnlineChangeCnt, TwinCATDataType.UDInt, ct).ConfigureAwait(false))
|
||||
?? prev?.OnlineChangeCnt;
|
||||
var cycleTicks = ToUInt(await ReadDiagSymbolAsync(state, client, SymCycleTime, TwinCATDataType.UDInt, ct).ConfigureAwait(false));
|
||||
var execTicks = ToUInt(await ReadDiagSymbolAsync(state, client, SymLastExecTime, TwinCATDataType.UDInt, ct).ConfigureAwait(false));
|
||||
var cycleMs = cycleTicks is { } c ? TicksToMilliseconds(c) : prev?.CycleTimeMs;
|
||||
var execMs = execTicks is { } x ? TicksToMilliseconds(x) : prev?.LastExecTimeMs;
|
||||
|
||||
state.LastDiagnostics = new TwinCATDeviceDiagnostics(adsState, appName, onlineChange, cycleMs, execMs, DateTime.UtcNow);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private static uint? ToUInt(object? v) => v switch
|
||||
{
|
||||
uint u => u,
|
||||
null => null,
|
||||
_ => Convert.ToUInt32(v, System.Globalization.CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
private async Task<object?> ReadDiagSymbolAsync(
|
||||
DeviceState state, ITwinCATClient client, string symbol, TwinCATDataType type, CancellationToken ct)
|
||||
{
|
||||
if (state.UnavailableDiagSymbols.ContainsKey(symbol)) return null;
|
||||
try
|
||||
{
|
||||
var (value, status) = await client.ReadValueAsync(symbol, type, null, null, ct).ConfigureAwait(false);
|
||||
if (status == TwinCATStatusMapper.Good) return value;
|
||||
if (status == TwinCATStatusMapper.BadNodeIdUnknown)
|
||||
state.UnavailableDiagSymbols[symbol] = 0;
|
||||
return null;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>/hosts</c> diagnostics bag (Gitea #640 channel): per device, ADS state, PLC app name, online
|
||||
/// change count, task cycle time, last execution time and jitter (last exec − cycle). A single-device
|
||||
/// driver uses flat <c>TwinCAT.*</c> keys; several devices prefix the device name (or host address).
|
||||
/// A pure in-memory read — the probe loop does the sampling. The host bounds the bag
|
||||
/// (<see cref="DriverDiagnostics.MaxEntries"/>), so a many-device driver keeps the first keys in
|
||||
/// ordinal order.
|
||||
/// </summary>
|
||||
/// <returns>The key/value pairs; empty before the first sample.</returns>
|
||||
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics()
|
||||
{
|
||||
var bag = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var multi = _devices.Count > 1;
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
if (state.LastDiagnostics is not { } d) continue;
|
||||
var prefix = multi ? $"TwinCAT.{state.Options.DeviceName ?? state.Options.HostAddress}." : "TwinCAT.";
|
||||
if (d.AdsState is not null) bag[prefix + "AdsState"] = d.AdsState;
|
||||
if (!string.IsNullOrEmpty(d.AppName)) bag[prefix + "AppName"] = d.AppName;
|
||||
if (d.OnlineChangeCnt is { } oc) bag[prefix + "OnlineChangeCnt"] = oc.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (d.CycleTimeMs is { } cy) bag[prefix + "CycleTimeMs"] = FormatMs(cy);
|
||||
if (d.LastExecTimeMs is { } ex) bag[prefix + "LastExecTimeMs"] = FormatMs(ex);
|
||||
if (d.JitterMs is { } j) bag[prefix + "JitterMs"] = FormatMs(j);
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
|
||||
// 0.1 ms resolution: the bag is published on a heartbeat and its digest is in the publish fingerprint,
|
||||
// so sub-0.1 ms wobble should not read as a change.
|
||||
private static string FormatMs(double ms) =>
|
||||
Math.Round(ms, 1, MidpointRounding.AwayFromZero).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
@@ -1234,10 +1439,22 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
public Task? ProbeTask { get; set; }
|
||||
|
||||
/// <summary>Disposes the active ADS client if any.</summary>
|
||||
/// <summary>Set when the target refused the ADS sum-read command (#645); the device reads one tag at a
|
||||
/// time from then on. Cleared only by a fresh <see cref="DeviceState"/> (driver re-initialise).</summary>
|
||||
public volatile bool SumReadUnsupported;
|
||||
|
||||
/// <summary>Latest probe-sampled PLC diagnostics (#645 / #314); null before the first sample.</summary>
|
||||
public TwinCATDeviceDiagnostics? LastDiagnostics { get; set; }
|
||||
|
||||
/// <summary>Diagnostic system symbols the target answered "not found" for — not re-asked until the client is rebuilt.</summary>
|
||||
public ConcurrentDictionary<string, byte> UnavailableDiagSymbols { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
Client = null;
|
||||
// A rebuilt client may face a PLC that now exports the symbols (e.g. after a program download).
|
||||
UnavailableDiagSymbols.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Disposes the connection gate semaphore.</summary>
|
||||
|
||||
@@ -78,6 +78,8 @@ public static class TwinCATDriverFactoryExtensions
|
||||
UseNativeNotifications = dto.UseNativeNotifications ?? true,
|
||||
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
||||
NotificationMaxDelayMs = dto.NotificationMaxDelayMs ?? 0,
|
||||
UseSumRead = dto.UseSumRead ?? false,
|
||||
SumReadMaxItems = dto.SumReadMaxItems ?? 200,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,6 +116,12 @@ public static class TwinCATDriverFactoryExtensions
|
||||
/// <summary>Gets or sets the maximum notification delay in milliseconds.</summary>
|
||||
public int? NotificationMaxDelayMs { get; init; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether a poll's reads are batched into ADS sum-reads.</summary>
|
||||
public bool? UseSumRead { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the maximum symbols per sum-read command.</summary>
|
||||
public int? SumReadMaxItems { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the list of configured devices.</summary>
|
||||
public List<TwinCATDeviceDto>? Devices { get; init; }
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="ITwinCATClient.ReadValuesAsync"/> when the TARGET refuses the ADS sum-read
|
||||
/// command as a whole (service or index group not supported). It is not a comms fault: the driver marks
|
||||
/// the device "no sum-read" and re-reads the batch with individual reads, so the poll still completes.
|
||||
/// </summary>
|
||||
internal sealed class TwinCATSumReadRefusedException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="TwinCATSumReadRefusedException"/> class.</summary>
|
||||
/// <param name="adsErrorCode">The overall ADS error code the target answered with.</param>
|
||||
/// <param name="inner">The underlying exception, if any.</param>
|
||||
public TwinCATSumReadRefusedException(uint adsErrorCode, Exception? inner = null)
|
||||
: base($"ADS sum-read refused by the target (ADS error {adsErrorCode})", inner)
|
||||
{
|
||||
AdsErrorCode = adsErrorCode;
|
||||
}
|
||||
|
||||
/// <summary>Gets the overall ADS error code.</summary>
|
||||
public uint AdsErrorCode { get; }
|
||||
}
|
||||
+20
@@ -43,6 +43,20 @@
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = push immediately. Increase to coalesce high-churn signals.</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox id="tcSumRead" @bind-Value="_form.UseSumRead" @bind-Value:after="EmitAsync"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="tcSumRead">Batch polled reads (ADS sum-read)</label>
|
||||
</div>
|
||||
<div class="form-text">One round-trip per chunk instead of per tag. Falls back to single reads if the target refuses it. Default off.</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="tcSumReadMax">Sum-read max items</label>
|
||||
<InputNumber id="tcSumReadMax" @bind-Value="_form.SumReadMaxItems" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Symbols per sum-read command (default 200).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -151,6 +165,8 @@
|
||||
public bool UseNativeNotifications { get; set; } = true;
|
||||
public bool EnableControllerBrowse { get; set; } = false;
|
||||
public int NotificationMaxDelayMs { get; set; } = 0;
|
||||
public bool UseSumRead { get; set; } = false;
|
||||
public int SumReadMaxItems { get; set; } = 200;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
@@ -168,6 +184,8 @@
|
||||
UseNativeNotifications = o.UseNativeNotifications,
|
||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
||||
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
|
||||
UseSumRead = o.UseSumRead,
|
||||
SumReadMaxItems = o.SumReadMaxItems,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
@@ -181,6 +199,8 @@
|
||||
UseNativeNotifications = UseNativeNotifications,
|
||||
EnableControllerBrowse = EnableControllerBrowse,
|
||||
NotificationMaxDelayMs = NotificationMaxDelayMs,
|
||||
UseSumRead = UseSumRead,
|
||||
SumReadMaxItems = SumReadMaxItems,
|
||||
Probe = new TwinCATProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
|
||||
Reference in New Issue
Block a user