fix(focas): a CNC refusal is per-tag Bad, not a driver Degraded (lane health checklist)
Applies the driver-lane health contract to the value path. Every non-Good read status used to write Degraded on every poll, so one vacant macro (BadNoData), one EW_NUMBER address (BadOutOfRange) or one fractional macro on an integer tag made a reachable CNC look sick forever. Now: - a comms-class status (BadCommunicationError / BadInternalError (EW_HANDLE) / BadTimeout) still degrades, with LastSuccessfulRead preserved; - any other non-Good status is a refusal: the tag reads Bad, one Warning is logged per tag (re-armed when it next reads Good, cleared at init), driver state is untouched, and LastSuccessfulRead advances because the CNC answered (CAS, since devices now read concurrently); - a write refused with NotSupportedException no longer degrades the driver. Refs #740 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -235,6 +235,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var generation = Interlocked.Increment(ref _lifecycleGeneration);
|
||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||
ResetAlarmHealth();
|
||||
_refusalWarned.Clear();
|
||||
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Initializing, null, null));
|
||||
var devices = new Dictionary<string, DeviceState>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
@@ -756,11 +757,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||
if (status == FocasStatusMapper.Good)
|
||||
{
|
||||
_refusalWarned.TryRemove(reference, out _);
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null));
|
||||
else
|
||||
}
|
||||
else if (IsCommsStatus(status))
|
||||
{
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
|
||||
Volatile.Read(ref _health).LastSuccessfulRead,
|
||||
$"FOCAS status 0x{status:X8} reading {reference}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
NoteRefusal(reference, def.Address, status, now);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||||
catch (OperationCanceledException)
|
||||
@@ -781,6 +791,43 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RawPaths whose CNC refusal has already been logged — a refusal warns ONCE per tag, not once per poll,
|
||||
/// and re-arms when the tag next reads Good. Cleared at initialise.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _refusalWarned = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// True for a status that says the LINK failed (or the read never got an answer), as opposed to the CNC
|
||||
/// answering with a refusal. <see cref="FocasStatusMapper.MapFocasReturn"/> maps EW_SOCKET / EW_UNEXP /
|
||||
/// EW_PARITY / unknown codes to BadCommunicationError and EW_HANDLE to BadInternalError; the wire client
|
||||
/// reports a dead session as BadCommunicationError.
|
||||
/// </summary>
|
||||
private static bool IsCommsStatus(uint status) => status is FocasStatusMapper.BadCommunicationError
|
||||
or FocasStatusMapper.BadInternalError or FocasStatusMapper.BadTimeout;
|
||||
|
||||
/// <summary>
|
||||
/// A deterministic CNC refusal (drivers.md health contract): per-tag Bad only — one Warning per tag, no
|
||||
/// state change — but the CNC DID answer, so LastSuccessfulRead advances (else a reachable control whose
|
||||
/// only tags are refused would look silent to the comms-loss watchdog).
|
||||
/// </summary>
|
||||
private void NoteRefusal(string reference, string address, uint status, DateTime now)
|
||||
{
|
||||
// CAS, not a plain write: devices are read concurrently, and a read-modify-write could otherwise put back
|
||||
// the state another device's comms fault had just replaced.
|
||||
while (true)
|
||||
{
|
||||
var current = Volatile.Read(ref _health);
|
||||
if (current.LastSuccessfulRead is { } last && last >= now) break;
|
||||
if (ReferenceEquals(Interlocked.CompareExchange(ref _health, current with { LastSuccessfulRead = now }, current), current))
|
||||
break;
|
||||
}
|
||||
if (_refusalWarned.TryAdd(reference, 0))
|
||||
_logger.LogWarning(
|
||||
"FOCAS CNC refused {RawPath} ({Address}) with 0x{Status:X8}; the tag reads Bad until it is fixed (warned once). Driver={DriverInstanceId}",
|
||||
reference, address, status, _driverInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PMC coalescing pre-pass for ONE device's share of a batch. Plans merged ranges
|
||||
/// (<see cref="FocasPmcCoalescer"/>) and serves every multi-member range with ONE
|
||||
@@ -956,9 +1003,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
// A refusal, not a comms fault (drivers.md, #611): nothing was wrong with the link, so health is
|
||||
// left alone — degrading here handed the comms-loss watchdog a reason to reconnect a healthy CNC.
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotSupported);
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
|
||||
Volatile.Read(ref _health).LastSuccessfulRead, nse.Message));
|
||||
_logger.LogDebug(nse, "FOCAS write to {RawPath} refused as unsupported", w.FullReference);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user