fix(twincat): warn once per bad tag; a device refusal is not a comms fault (P10)

CompleteRead logged a Warning for every non-Good status on every poll and
latched Degraded for all of them. Now a bad tag warns once per bad episode
(re-armed when it reads Good, and on re-initialise), a comms-class status
(BadCommunicationError / BadTimeout) degrades with LastSuccessfulRead
preserved, and any other status - the device answered and refused this tag -
advances LastSuccessfulRead with no state change. Read-path exception
handlers now go through MarkCommsFault, so they never downgrade Faulted.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:07:03 -04:00
parent ecdd6dbecb
commit 4083b4aada
3 changed files with 175 additions and 13 deletions
@@ -42,6 +42,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// so concurrent bit-writers to the same word must serialise or they lose each other's
// updates. Keyed by device + parent symbol. (Cannot guard against the PLC program writing
// the word between our read and write — inherent to RMW.)
// References that already logged their bad-read Warning in the current bad episode (#739 P10).
// Removed when the tag reads Good; cleared on (re-)initialise and shutdown.
private readonly ConcurrentDictionary<string, byte> _warnedRefs = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SemaphoreSlim> _bitRmwLocks =
new(StringComparer.OrdinalIgnoreCase);
@@ -117,6 +121,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + clients before
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
_warnedRefs.Clear();
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
@@ -335,7 +340,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
MarkCommsFault(ex.Message);
return new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
}
}
@@ -346,25 +351,53 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
if (status == TwinCATStatusMapper.Good)
{
_warnedRefs.TryRemove(reference, out _); // a later failure is a new episode and warns again
RecordDeviceAnswered(now, force: true);
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);
// Once per tag per bad episode: a Warning every poll for every bad tag (50 typo'd symbols on a
// 500 ms poll = 100 Warnings/s) buried everything else in the log.
if (_warnedRefs.TryAdd(reference, 0))
{
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); logged once until the tag reads Good",
_driverInstanceId, reference, def.DataType, symbolName);
else
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'; " +
"logged once until the tag reads Good",
_driverInstanceId, status, reference);
}
if (IsCommsStatus(status))
MarkCommsFault($"ADS status {status:X8} reading {reference}");
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}");
RecordDeviceAnsweredKeepState(now); // a refusal: the device answered — no state change
return new DataValueSnapshot(value, status, now, now);
}
/// <summary>
/// True for a status that means the request did not get a device answer (router / transport /
/// timeout). Every other non-Good status is the device refusing this one tag (symbol not found, type
/// mismatch, PLC not running, access denied): per-tag Bad with no health write.
/// </summary>
/// <param name="status">The mapped OPC UA status.</param>
/// <returns>True for a comms-class status.</returns>
internal static bool IsCommsStatus(uint status) =>
status is TwinCATStatusMapper.BadCommunicationError or TwinCATStatusMapper.BadTimeout;
/// <summary>Advances <c>LastSuccessfulRead</c> without changing the state (the device answered with a refusal).</summary>
private void RecordDeviceAnsweredKeepState(DateTime now)
{
var current = _health;
if (current.State is DriverState.Faulted or DriverState.Unknown or DriverState.Initializing) return;
_health = current with { LastSuccessfulRead = 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
@@ -430,7 +463,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
foreach (var e in chunk)
results[e.Index] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
MarkCommsFault(ex.Message);
// Once per device: the health message is overwritten by the next good read in the same poll, so
// without this a sum-read path failing on every poll left no trace (hw2 live gate, 2026-10-08).
if (connected && !device.SumChunkFailureWarned)