diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDecodeException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDecodeException.cs
new file mode 100644
index 00000000..06d9a639
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDecodeException.cs
@@ -0,0 +1,17 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// The device answered with well-framed data the tag's codec cannot represent (a non-BCD nibble in a
+/// BCD register, for example). The device is reachable, so this is a per-tag
+/// BadDecodingError with a once-per-tag Warning — never a comms fault, never a health write.
+///
+public sealed class ModbusDecodeException : Exception
+{
+ /// Initializes a new instance of the class.
+ /// A human-readable description.
+ /// The codec's own exception.
+ public ModbusDecodeException(string message, Exception? inner = null)
+ : base(message, inner)
+ {
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
index fc85ddde..648e4c99 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
@@ -43,7 +43,33 @@ public sealed class ModbusDriver
// v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is
// case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
- private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
+ // BUILD-AND-SWAP: a fresh dictionary is built off to the side and published by reference
+ // (Volatile.Write, under _stateLock with the generation check); it is never mutated or .Clear()ed
+ // in place, because retained poll loops read it concurrently across a re-initialize.
+ private Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
+
+ ///
+ /// Lifecycle generation. Every InitializeAsync / ShutdownAsync bumps it under
+ /// ; every health write and every table/transport publish carries the
+ /// generation it was computed under and is DROPPED once the generation has moved on — so a
+ /// superseded (abandoned) initialize can never publish over a newer one, a read that straddled a
+ /// re-initialize cannot write health about the new transport, and a shutdown's final health
+ /// write cannot clobber a newer init.
+ ///
+ private int _generation;
+
+ /// Guards , the transport/table publish and the health fields.
+ private readonly object _stateLock = new();
+
+ // Health is composed from two independent sides so a good write clears only a WRITE-side
+ // Degraded (and a good read only a read-side one). LastSuccessfulRead advances on any evidence the
+ // device answered — a good read, a good write, a refusal, or a probe answer.
+ private string? _readFault;
+ private string? _writeFault;
+
+ // Once-per-tag Warning for deterministic device refusals (exception 01/02/03, undecodable data).
+ // Replaced (never cleared in place) on every table publish.
+ private ConcurrentDictionary _refusalWarned = new(StringComparer.Ordinal);
// Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the
// authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss).
@@ -87,6 +113,11 @@ public sealed class ModbusDriver
private IModbusTransport? _transport;
private CancellationTokenSource? _probeCts;
private CancellationTokenSource? _reprobeCts;
+ private Task? _probeTask;
+ private Task? _reprobeTask;
+
+ /// Bounded join on the probe / re-probe loops at teardown — they honour their token, so this is a backstop.
+ private static readonly TimeSpan BackgroundLoopJoinTimeout = TimeSpan.FromSeconds(2);
///
/// Guards / /
@@ -105,7 +136,7 @@ public sealed class ModbusDriver
/// (a connect that never returns) must not turn shutdown or dispose into a hang — past the
/// timeout they tear down anyway, which is the lesser evil of the two.
///
- private static readonly TimeSpan LifecycleGateTimeout = TimeSpan.FromSeconds(10);
+ internal TimeSpan LifecycleGateTimeout { get; set; } = TimeSpan.FromSeconds(10);
private DriverHealth _health = new(DriverState.Unknown, null, null);
@@ -152,7 +183,7 @@ public sealed class ModbusDriver
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger.Instance;
_resolver = new EquipmentTagRefResolver(
- r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
+ r => Volatile.Read(ref _tagsByRawPath).TryGetValue(r, out var t) ? t : null);
_transportFactory = transportFactory
?? (o => ModbusTransportFactory.Create(o));
_poll = new PollGroupEngine(
@@ -208,9 +239,151 @@ public sealed class ModbusDriver
internal void HandlePollError(Exception ex)
{
_logger.LogWarning(ex, "Modbus poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
- var current = ReadHealth();
- if (current.State != DriverState.Faulted)
- WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message));
+ lock (_stateLock)
+ {
+ // Lifecycle-owned states are left alone: Faulted is a stronger, config-level signal, and an
+ // Initializing/Unknown driver has no transport for a poll to have failed against — the
+ // failure is the re-initialize window itself, which the lifecycle reports on its own.
+ var current = _health;
+ if (current.State is DriverState.Faulted or DriverState.Initializing or DriverState.Unknown) return;
+ _readFault = ex.Message;
+ PublishComposedHealthLocked(current.LastSuccessfulRead);
+ }
+ }
+
+ // ---- health composition ----
+
+ /// Which half of the driver a batch outcome came from.
+ private enum HealthSide { Read, Write }
+
+ ///
+ /// Per-batch accounting: did the device answer anything, did a comms fault happen, and which units
+ /// (or the whole endpoint) must the rest of this batch fail fast against.
+ ///
+ private sealed class BatchOutcome
+ {
+ public bool Answered;
+ public string? CommsFault;
+ public bool EndpointDown;
+ private HashSet? _failedUnits;
+
+ public void FailUnit(byte unit) => (_failedUnits ??= []).Add(unit);
+
+ public bool IsFailedFast(byte unit) => EndpointDown || (_failedUnits?.Contains(unit) ?? false);
+ }
+
+ ///
+ /// Applies one batch's outcome to the driver health, under , and only when
+ /// is still current. A batch that touched no device (every reference
+ /// unknown, every write refused before the wire) changes nothing.
+ ///
+ private void ReportOutcome(int generation, HealthSide side, BatchOutcome batch, DateTime now)
+ {
+ if (!batch.Answered && batch.CommsFault is null) return;
+ lock (_stateLock)
+ {
+ if (generation != _generation) return;
+ var current = _health;
+ if (current.State is DriverState.Faulted or DriverState.Initializing or DriverState.Unknown) return;
+ if (side == HealthSide.Read) _readFault = batch.CommsFault;
+ else _writeFault = batch.CommsFault;
+ PublishComposedHealthLocked(batch.Answered ? now : current.LastSuccessfulRead);
+ }
+ }
+
+ /// Writes Healthy/Degraded from the two fault sides. Caller holds .
+ private void PublishComposedHealthLocked(DateTime? lastSuccessfulRead)
+ {
+ var fault = _readFault ?? _writeFault;
+ WriteHealth(fault is null
+ ? new DriverHealth(DriverState.Healthy, lastSuccessfulRead, null)
+ : new DriverHealth(DriverState.Degraded, lastSuccessfulRead, fault));
+ }
+
+ ///
+ /// A probe answer proves the device is reachable: advance LastSuccessfulRead without
+ /// touching either fault side (it says nothing about the tags).
+ ///
+ private void NoteDeviceAnswered(int generation)
+ {
+ lock (_stateLock)
+ {
+ if (generation != _generation) return;
+ var current = _health;
+ if (current.State is not (DriverState.Healthy or DriverState.Degraded)) return;
+ WriteHealth(current with { LastSuccessfulRead = DateTime.UtcNow });
+ }
+ }
+
+ /// Lifecycle health write, dropped when has been superseded.
+ private void WriteLifecycleHealth(int generation, DriverHealth health)
+ {
+ lock (_stateLock)
+ {
+ if (generation != _generation) return;
+ _readFault = null;
+ _writeFault = null;
+ WriteHealth(health);
+ }
+ }
+
+ ///
+ /// Classifies one failed read/write against the health contract and returns the per-tag status:
+ ///
+ /// Exception 01/02/03 — a deterministic REFUSAL: the device answered; per-tag Bad, a
+ /// once-per-tag Warning, no Degraded (it used to latch Degraded and, if every tag refused,
+ /// trip the 90 s watchdog against a reachable device).
+ /// Exception 04/05/06 — the device answered but is not serving: Degraded, answered.
+ /// Exception 0A/0B (and unknown codes) — the gateway answered for a target that did not:
+ /// Degraded, not answered; 0B fails the rest of that unit's batch fast.
+ /// Undecodable data — the device answered with a value we cannot represent: per-tag
+ /// BadDecodingError, once-per-tag Warning, no Degraded.
+ /// No socket / throttled reconnect — the endpoint is down: Degraded, the rest of the
+ /// batch fails fast.
+ /// Any other transport failure (timeout, desync, EOF) — Degraded; the rest of that
+ /// unit's batch fails fast instead of serialising N timeouts.
+ ///
+ ///
+ private uint ClassifyFailure(Exception ex, byte unit, string tagRef, BatchOutcome batch, bool isWrite)
+ {
+ switch (ex)
+ {
+ case ModbusException mex when IsRefusalCode(mex.ExceptionCode):
+ batch.Answered = true;
+ WarnRefusalOnce(tagRef, isWrite, $"exception 0x{mex.ExceptionCode:X2} (fc 0x{mex.FunctionCode:X2})");
+ return MapModbusExceptionToStatus(mex.ExceptionCode);
+ case ModbusException mex when mex.ExceptionCode is 0x04 or 0x05 or 0x06:
+ batch.Answered = true;
+ batch.CommsFault ??= mex.Message;
+ return MapModbusExceptionToStatus(mex.ExceptionCode);
+ case ModbusException mex:
+ batch.CommsFault ??= mex.Message;
+ if (mex.ExceptionCode == 0x0B) batch.FailUnit(unit);
+ return MapModbusExceptionToStatus(mex.ExceptionCode);
+ case ModbusDecodeException dex:
+ batch.Answered = true;
+ WarnRefusalOnce(tagRef, isWrite, dex.Message);
+ return StatusBadDecodingError;
+ case ModbusTransportUnavailableException or ObjectDisposedException or System.Net.Sockets.SocketException:
+ batch.CommsFault ??= ex.Message;
+ batch.EndpointDown = true;
+ return StatusBadCommunicationError;
+ default:
+ batch.CommsFault ??= ex.Message;
+ batch.FailUnit(unit);
+ return StatusBadCommunicationError;
+ }
+ }
+
+ /// Exception 01 Illegal Function / 02 Illegal Data Address / 03 Illegal Data Value — the device refused the request.
+ internal static bool IsRefusalCode(byte exceptionCode) => exceptionCode is 0x01 or 0x02 or 0x03;
+
+ private void WarnRefusalOnce(string tagRef, bool isWrite, string detail)
+ {
+ if (!Volatile.Read(ref _refusalWarned).TryAdd((isWrite ? "w:" : "r:") + tagRef, 0)) return;
+ _logger.LogWarning(
+ "Modbus device refused {Operation} of tag {RawPath}: {Detail}. The tag reads/writes Bad; driver health is unaffected (the device answered). Driver={DriverInstanceId}",
+ isWrite ? "a write" : "a read", tagRef, detail, _driverInstanceId);
}
//
@@ -310,11 +483,26 @@ public sealed class ModbusDriver
/// The gate-held body of .
private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
+ // Claim a new generation and take the previous transport out of service atomically: any read or
+ // write that captured the old generation can no longer write health, and nothing can capture the
+ // old transport from here on.
+ int generation;
+ IModbusTransport? previous;
+ lock (_stateLock)
+ {
+ generation = ++_generation;
+ previous = _transport;
+ _transport = null;
+ _readFault = null;
+ _writeFault = null;
+ WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
+ }
+
+ IModbusTransport? transport = null;
try
{
// Release whatever a PREVIOUS initialize published before building the replacements. All
- // three fields assigned below (_transport, _probeCts, _reprobeCts) were simply overwritten,
+ // fields assigned below (_transport, _probeCts, _reprobeCts) were simply overwritten,
// so a second initialize on a live driver stranded a connected transport plus two running
// background loops that no later dispose could reach. The IDriver contract lets a
// caller re-initialise a live driver without ShutdownAsync, and DriverInstanceActor's retry tick
@@ -323,20 +511,9 @@ public sealed class ModbusDriver
// every ACTIVE SUBSCRIPTION — and nothing re-subscribes them afterwards, because a
// mid-life re-initialize is not a shutdown the OPC UA layer knows to re-drive. A caller
// that genuinely wants the subscriptions gone calls ShutdownAsync. A first initialize
- // sees three nulls, so this block is a no-op on the initial call.
- try { _probeCts?.Cancel(); } catch { }
- _probeCts?.Dispose();
- _probeCts = null;
-
- try { _reprobeCts?.Cancel(); } catch { }
- _reprobeCts?.Dispose();
- _reprobeCts = null;
-
- if (_transport is not null)
- {
- await _transport.DisposeAsync().ConfigureAwait(false);
- _transport = null;
- }
+ // sees nulls, so this block is a no-op on the initial call.
+ await StopBackgroundLoopsAsync().ConfigureAwait(false);
+ if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
// re-parse the supplied DriverConfig so a config change delivered through the IDriver
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
@@ -345,17 +522,19 @@ public sealed class ModbusDriver
if (HasConfigBody(driverConfigJson))
_options = ModbusDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
- _transport = _transportFactory(_options);
- await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
- // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
- // blob is mapped by the pure factory; the entry's WriteIdempotent flag is threaded onto the
- // def (it lives on the RawTagEntry, not inside the blob). A mapper miss is skipped (logged),
- // never thrown — a bad tag must not fail the whole driver init.
+ transport = _transportFactory(_options);
+ await transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
+
+ // Build the RawPath → definition table OFF TO THE SIDE from the authored raw tags. Each
+ // entry's TagConfig blob is mapped by the pure factory; the entry's WriteIdempotent flag is
+ // threaded onto the def (it lives on the RawTagEntry, not inside the blob). A mapper miss is
+ // skipped (logged), never thrown — a bad tag must not fail the whole driver init.
+ var table = new Dictionary(StringComparer.Ordinal);
_skippedTags.BeginRebuild(_options.RawTags.Count);
foreach (var entry in _options.RawTags)
{
if (ModbusTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
- _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
+ table[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
else
{
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
@@ -365,30 +544,76 @@ public sealed class ModbusDriver
}
}
_skippedTags.Commit();
- WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
- // PR 23: kick off the probe loop once the transport is up. Initial state stays
- // Unknown until the first probe tick succeeds — avoids broadcasting a premature
- // Running transition before any register round-trip has happened.
- if (_options.Probe.Enabled)
+ // Publish transport + table + Healthy as ONE generation-checked step. A superseded attempt
+ // (a shutdown/initialize ran while this one was abandoned mid-connect) publishes nothing and
+ // releases its own transport.
+ lock (_stateLock)
{
- _probeCts = new CancellationTokenSource();
- _ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
- }
+ if (generation != _generation)
+ throw new OperationCanceledException(
+ "Modbus initialize was superseded by a later lifecycle operation; its transport was released");
+ _transport = transport;
+ Volatile.Write(ref _tagsByRawPath, table);
+ Volatile.Write(ref _refusalWarned, new ConcurrentDictionary(StringComparer.Ordinal));
+ WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
- if (_options.AutoProhibitReprobeInterval is not null)
- {
- _reprobeCts = new CancellationTokenSource();
- _ = Task.Run(() => ReprobeLoopAsync(_reprobeCts.Token), _reprobeCts.Token);
+ // PR 23: kick off the probe loop once the transport is up. Initial state stays
+ // Unknown until the first probe tick succeeds — avoids broadcasting a premature
+ // Running transition before any register round-trip has happened. Started inside the
+ // generation-checked publish so a teardown that bypassed the lifecycle gate can never
+ // run between the publish and the loop start and strand a loop.
+ if (_options.Probe.Enabled)
+ {
+ var cts = new CancellationTokenSource();
+ var probeTransport = transport;
+ _probeCts = cts;
+ _probeTask = Task.Run(() => ProbeLoopAsync(generation, probeTransport, cts.Token), cts.Token);
+ }
+
+ if (_options.AutoProhibitReprobeInterval is not null)
+ {
+ var cts = new CancellationTokenSource();
+ _reprobeCts = cts;
+ _reprobeTask = Task.Run(() => ReprobeLoopAsync(cts.Token), cts.Token);
+ }
}
+ transport = null; // published — owned by the driver now
}
catch (Exception ex)
{
- WriteHealth(new DriverHealth(DriverState.Faulted, null, ex.Message));
+ // A failed attempt releases the transport it built — it is never left half-published.
+ if (transport is not null)
+ {
+ try { await transport.DisposeAsync().ConfigureAwait(false); } catch { }
+ }
+ WriteLifecycleHealth(generation, new DriverHealth(DriverState.Faulted, null, ex.Message));
throw;
}
}
+ /// Cancels, bounded-joins and disposes the probe and re-probe loops. Idempotent.
+ private async Task StopBackgroundLoopsAsync()
+ {
+ var probeCts = _probeCts;
+ var reprobeCts = _reprobeCts;
+ var tasks = new[] { _probeTask, _reprobeTask }.Where(t => t is not null).Cast().ToArray();
+ _probeCts = null;
+ _reprobeCts = null;
+ _probeTask = null;
+ _reprobeTask = null;
+
+ try { probeCts?.Cancel(); } catch { }
+ try { reprobeCts?.Cancel(); } catch { }
+ if (tasks.Length > 0)
+ {
+ try { await Task.WhenAll(tasks).WaitAsync(BackgroundLoopJoinTimeout).ConfigureAwait(false); }
+ catch { /* a loop that ignores its token is abandoned, never awaited forever */ }
+ }
+ probeCts?.Dispose();
+ reprobeCts?.Dispose();
+ }
+
///
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
@@ -435,9 +660,17 @@ public sealed class ModbusDriver
/// .
private async Task ShutdownCoreAsync()
{
- var lastRead = ReadHealth().LastSuccessfulRead;
+ int generation;
+ DateTime? lastRead;
+ lock (_stateLock)
+ {
+ // A shutdown is a lifecycle operation too: it supersedes any in-flight (abandoned) initialize,
+ // and its own final write below is dropped if a NEWER initialize started meanwhile.
+ generation = ++_generation;
+ lastRead = _health.LastSuccessfulRead;
+ }
await TeardownAsync().ConfigureAwait(false);
- WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
+ WriteLifecycleHealth(generation, new DriverHealth(DriverState.Unknown, lastRead, null));
}
///
@@ -489,57 +722,83 @@ public sealed class ModbusDriver
public async Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
- var transport = RequireTransport();
+ var (generation, transport, table) = CaptureLifecycle();
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
+ var batch = new BatchOutcome();
var coalesced = _options.MaxReadGap > 0
- ? await ReadCoalescedAsync(transport, fullReferences, results, now, cancellationToken).ConfigureAwait(false)
+ ? await ReadCoalescedAsync(transport, table, fullReferences, results, now, batch, cancellationToken).ConfigureAwait(false)
: new HashSet();
for (var i = 0; i < fullReferences.Count; i++)
{
if (coalesced.Contains(i)) continue;
- if (!_resolver.TryResolve(fullReferences[i], out var tag))
+ if (!table.TryGetValue(fullReferences[i], out var tag))
{
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
continue;
}
+ var unit = ResolveUnitId(tag);
+ if (batch.IsFailedFast(unit))
+ {
+ // Fail fast: this unit (or the whole endpoint) already failed at the transport this batch;
+ // paying the same timeout once per remaining tag would stall the poll N-fold.
+ results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
+ continue;
+ }
try
{
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, 0u, now, now);
- WriteHealth(new DriverHealth(DriverState.Healthy, now, null));
+ batch.Answered = true;
// Invalidate the WriteOnChangeOnly cache when the read returns a different value
// — typically an HMI-side or PLC-internal change. Without this, a setpoint
// tweaked at the panel could be silently re-suppressed when our client tried
// to restore it.
- if (_options.WriteOnChangeOnly)
- {
- lock (_lastWrittenLock)
- {
- if (_lastWrittenByRef.TryGetValue(fullReferences[i], out var prev) && !Equals(prev, value))
- _lastWrittenByRef.Remove(fullReferences[i]);
- }
- }
+ InvalidateWriteCacheIfDiverged(fullReferences[i], value);
}
- catch (ModbusException mex)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
- results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
- WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, mex.Message));
+ throw;
}
catch (Exception ex)
{
- // Non-Modbus-layer failure: socket dropped, timeout, malformed response. Surface
- // as communication error so callers can distinguish it from tag-level faults.
- results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
- WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, ex.Message));
+ results[i] = new DataValueSnapshot(null, ClassifyFailure(ex, unit, fullReferences[i], batch, isWrite: false), null, now);
}
}
+
+ // ONE health write per batch, worst-wins over the batch, generation-checked. It used to be
+ // last-writer-wins per tag, so health flapped with tag order.
+ ReportOutcome(generation, HealthSide.Read, batch, now);
return results;
}
+ ///
+ /// Captures the generation, transport and tag table a read/write batch runs against, atomically
+ /// with respect to a lifecycle publish.
+ ///
+ private (int Generation, IModbusTransport Transport, Dictionary Table) CaptureLifecycle()
+ {
+ lock (_stateLock)
+ {
+ var transport = _transport ?? throw new InvalidOperationException("ModbusDriver not initialized");
+ return (_generation, transport, _tagsByRawPath);
+ }
+ }
+
+ /// Runs a codec decode, normalising its failure to so the
+ /// classifier can tell undecodable device data from a transport fault.
+ private static object Decode(Func