Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
T
Joseph Doherty 66867ca8f6 fix(modbus): snapshot probe/reprobe CancellationToken value before scheduling (#729)
The probe and re-probe worker delegates closed over the CTS and read cts.Token
when they ran. Task.Run's scheduling token only suppresses a still-queued task;
a worker the pool had committed to but not yet invoked could outlive the bounded
join in StopBackgroundLoopsAsync, after which the CTS was disposed and the
delayed cts.Token read threw ObjectDisposedException (faulting the loop task).

Snapshot the token value before Task.Run and close over it; a token value stays
valid after its source is disposed and still reports cancellation, so a late
worker exits cleanly on its own generation's cancelled token. Document the
bounded-join / transport-lifetime limits on StopBackgroundLoopsAsync.

Regression tests park the worker deterministically in TPL's synchronous
TaskStarted event (after the start check, before the delegate runs) via an
EventListener correlated to the one TaskScheduled on the test's armed flow:
shutdown (probe + reprobe) and replacement-generation identity. All three fail
with ObjectDisposedException without the fix.
2026-11-09 12:04:53 -05:00

2411 lines
122 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Modbus TCP implementation of <see cref="IDriver"/> + <see cref="ITagDiscovery"/> +
/// <see cref="IReadable"/> + <see cref="IWritable"/>. First native-protocol greenfield
/// driver for the v2 stack — validates the driver-agnostic <c>IAddressSpaceBuilder</c> +
/// <c>IReadable</c>/<c>IWritable</c> abstractions generalize beyond Galaxy.
/// </summary>
/// <remarks>
/// Scope limits: Historian + alarm capabilities are out of scope (the protocol doesn't
/// express them). Subscriptions overlay a polling loop via the shared
/// <see cref="PollGroupEngine"/> since Modbus has no native push model.
/// </remarks>
public sealed class ModbusDriver
: IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table.</summary>
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// ---- instance fields (grouped at top for auditability) ----
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize.
/// Only ever written on the init path, before any reader/transport uses it.</summary>
private ModbusDriverOptions _options;
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
private readonly string _driverInstanceId;
private readonly ILogger<ModbusDriver> _logger;
// Polled subscriptions delegate to the shared PollGroupEngine. The driver only supplies
// the reader + on-change bridge; the engine owns the loop, interval floor, and lifecycle.
private readonly PollGroupEngine _poll;
// 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.
// 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<string, ModbusTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
/// <summary>
/// Lifecycle generation. Every InitializeAsync / ShutdownAsync bumps it under
/// <see cref="_stateLock"/>; 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.
/// </summary>
private int _generation;
/// <summary>Guards <see cref="_generation"/>, the transport/table publish and the health fields.</summary>
private readonly object _stateLock = new();
// ONE comms-fault slot for reads AND writes: a fault is a property of the shared transport, so the
// latest batch of either kind decides — a good read clears a Degraded a write's timeout raised and vice
// versa (two independent sides latched a single write blip as Degraded forever in a read-mostly plant).
// LastSuccessfulRead advances on any evidence the device answered — a good read, a good write, a
// refusal, or a probe answer.
private string? _commsFault;
// 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<string, byte> _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).
private readonly EquipmentTagRefResolver<ModbusTagDefinition> _resolver;
// Last-published (value, status) per tag, keyed by FullReference. Used by ShouldPublish to apply
// the deadband filter. The value is stored as object so all numeric types share one map; the
// comparison does a typed cast inside. The status is part of the baseline — see ShouldPublish.
private readonly ConcurrentDictionary<string, (object Value, uint Status)> _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);
// Per-cycle buffer of the items this driver actually PUBLISHED (i.e. that passed ShouldPublish),
// keyed by subscription handle. The poll engine collects its own batch list unconditionally — it
// has no knowledge of the deadband filter — so the batch event is raised from this buffer instead,
// using the engine's onBatch purely as the end-of-cycle signal.
//
// No lock is needed on the per-handle List: the engine runs one loop task per subscription, so for
// ONE handle all of a cycle's onChange calls and that cycle's single onBatch run sequentially on
// that subscription's own task. Different subscriptions run concurrently — hence the per-handle
// ConcurrentDictionary.
private readonly ConcurrentDictionary<ISubscriptionHandle, List<DataChangeItem>> _cycleBatchItems = new();
// Last-written value per tag for the WriteOnChangeOnly suppression. Invalidated by reads
// that return a different value (so an HMI-side change doesn't get masked).
private readonly Dictionary<string, object?> _lastWrittenByRef = new(StringComparer.OrdinalIgnoreCase);
private readonly object _lastWrittenLock = new();
// BitInRegister writes need a read-modify-write against the full holding register. A
// per-register lock keeps concurrent bit-write callers from stomping on each other.
private readonly ConcurrentDictionary<ushort, SemaphoreSlim> _rmwLocks = new();
private readonly Dictionary<(byte Unit, ModbusRegion Region, ushort Start, ushort End), ProhibitionState> _autoProhibited = new();
private readonly object _autoProhibitedLock = new();
// Single-host probe state — Modbus driver talks to exactly one endpoint so the "hosts"
// collection has at most one entry. HostName is the Host:Port string so the Admin UI can
// display the PLC endpoint uniformly with Galaxy platforms/engines.
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private IModbusTransport? _transport;
private CancellationTokenSource? _probeCts;
private CancellationTokenSource? _reprobeCts;
private Task? _probeTask;
private Task? _reprobeTask;
/// <summary>Bounded join on the probe / re-probe loops at teardown — they honour their token, so this is a backstop.</summary>
private static readonly TimeSpan BackgroundLoopJoinTimeout = TimeSpan.FromSeconds(2);
/// <summary>
/// Guards <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> /
/// <see cref="ShutdownAsync"/> / <see cref="DisposeAsync"/> against each other. The three
/// fields above are published by one lifecycle operation and released by the next, so two
/// concurrent operations would dispose each other's freshly-built transport or null
/// <c>_transport</c> out from under an in-flight connect. Not hypothetical:
/// <c>DriverInstanceActor</c> dispatches initialize on a fire-and-forget <c>Task.Run</c> and
/// a reconnect retry tick can start a second attempt while the first is still running. Same shape as
/// <c>MqttDriver._lifecycleGate</c>.
/// </summary>
private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
/// <summary>
/// Bounded wait the teardown paths take on <see cref="_lifecycleGate"/>. A wedged initialize
/// (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.
/// </summary>
internal TimeSpan LifecycleGateTimeout { get; set; } = TimeSpan.FromSeconds(10);
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>Occurs when a subscribed tag value changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>
/// Occurs once per poll cycle that produced at least one change, carrying that cycle's
/// changed tags. Both data-change events fire by design — this one once per cycle,
/// <see cref="OnDataChange"/> once per changed tag — so a consumer subscribes to EXACTLY
/// ONE of the two; subscribing both double-delivers.
/// </summary>
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
/// <summary>Occurs when host connectivity status changes.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
// ---- nested types ----
/// <summary>
/// Per-prohibition state. <c>SplitPending</c> drives the re-probe loop's
/// bisection: when true and the range spans &gt; 1 register, the next re-probe
/// tries the two halves separately to narrow the actual offending register(s).
/// Single-register prohibitions can't be split further; they stay re-probed as-is.
/// </summary>
private sealed class ProhibitionState
{
public DateTime LastProbedUtc;
public bool SplitPending;
}
// ---- ctor + identity ----
/// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
/// <param name="options">Driver configuration options.</param>
/// <param name="driverInstanceId">Unique identifier for this driver instance.</param>
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to
/// <see cref="ModbusTransportFactory.Create"/>, which honours <see cref="ModbusDriverOptions.Transport"/>.</param>
/// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
ILogger<ModbusDriver>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger<ModbusDriver>.Instance;
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
r => Volatile.Read(ref _tagsByRawPath).TryGetValue(r, out var t) ? t : null);
_transportFactory = transportFactory
?? (o => ModbusTransportFactory.Create(o));
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
{
if (!ShouldPublish(tagRef, snapshot)) return;
// Buffer what we publish so the cycle-end batch carries exactly the same items.
// The append stays OUTSIDE the catch below on purpose: as far as the batch contract
// is concerned the item WAS published — only the per-tag subscriber faulted — so it
// must still appear in this cycle's batch, keeping the two events content-identical.
_cycleBatchItems.GetOrAdd(handle, _ => new List<DataChangeItem>())
.Add(new DataChangeItem(tagRef, snapshot));
// Defensive, mirroring PollGroupEngine.ReportError: the engine invokes this bridge
// UNGUARDED, so a throwing external subscriber would unwind PollOnceAsync mid-loop
// and the cycle would never reach onBatch. Because the buffer above persists across
// cycles (it is drained only by onBatch), those items would then silently MERGE into
// a later cycle's batch — cross-cycle contamination unique to this driver. Swallowing
// is what keeps the cycle running so onBatch drains the buffer on schedule.
try { OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)); }
catch { /* a buggy subscriber must not corrupt the poll cycle */ }
},
onError: HandlePollError,
backoffCap: PollBackoffCap,
// The engine's own items list is deliberately IGNORED: it is collected pre-deadband
// (PollGroupEngine has no knowledge of ShouldPublish), so forwarding it would carry
// changes the per-tag event suppressed — and a consumer that prefers the batch event
// would see deadband silently stop working. The buffer holds exactly what the per-tag
// event published, keeping the two events content-identical per the IBatchSubscribable
// contract. onBatch is used only as the end-of-cycle signal. A cycle whose every change
// was suppressed leaves the buffer empty and raises nothing.
onBatch: (handle, _) =>
{
if (_cycleBatchItems.TryRemove(handle, out var published) && published.Count > 0)
OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, published));
},
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
// closure IS the production consumer of its overrun signal.
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.Modbus),
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId)));
}
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide.</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
/// <summary>
/// Routes a poll-loop reader failure to the driver health surface: logs it and
/// degrades to <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>.
/// Never downgrades a <see cref="DriverState.Faulted"/> state (a stronger, config-level signal).
/// </summary>
/// <param name="ex">The exception caught by the poll engine.</param>
internal void HandlePollError(Exception ex)
{
_logger.LogWarning(ex, "Modbus poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
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;
_commsFault = ex.Message;
PublishComposedHealthLocked(current.LastSuccessfulRead);
}
}
// ---- health composition ----
/// <summary>
/// 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.
/// </summary>
private sealed class BatchOutcome
{
public bool Answered;
public string? CommsFault;
public bool EndpointDown;
private HashSet<byte>? _failedUnits;
public void FailUnit(byte unit) => (_failedUnits ??= []).Add(unit);
public bool IsFailedFast(byte unit) => EndpointDown || (_failedUnits?.Contains(unit) ?? false);
}
/// <summary>
/// Applies one batch's outcome to the driver health, under <see cref="_stateLock"/>, and only when
/// <paramref name="generation"/> is still current. A batch that touched no device (every reference
/// unknown, every write refused before the wire) changes nothing.
/// </summary>
private void ReportOutcome(int generation, 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;
_commsFault = batch.CommsFault;
PublishComposedHealthLocked(batch.Answered ? now : current.LastSuccessfulRead);
}
}
/// <summary>Writes Healthy/Degraded from the comms-fault slot. Caller holds <see cref="_stateLock"/>.</summary>
private void PublishComposedHealthLocked(DateTime? lastSuccessfulRead)
{
var fault = _commsFault;
WriteHealth(fault is null
? new DriverHealth(DriverState.Healthy, lastSuccessfulRead, null)
: new DriverHealth(DriverState.Degraded, lastSuccessfulRead, fault));
}
/// <summary>
/// A probe answer proves the device is reachable: advance <c>LastSuccessfulRead</c> without
/// touching either fault side (it says nothing about the tags).
/// </summary>
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 });
}
}
/// <summary>Lifecycle health write, dropped when <paramref name="generation"/> has been superseded.</summary>
private void WriteLifecycleHealth(int generation, DriverHealth health)
{
lock (_stateLock)
{
if (generation != _generation) return;
_commsFault = null;
WriteHealth(health);
}
}
/// <summary>
/// Classifies one failed read/write against the health contract and returns the per-tag status:
/// <list type="bullet">
/// <item>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).</item>
/// <item>Exception 04/05/06 — the device answered but is not serving: Degraded, answered.</item>
/// <item>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.</item>
/// <item>Undecodable data — the device answered with a value we cannot represent: per-tag
/// <c>BadDecodingError</c>, once-per-tag Warning, no Degraded.</item>
/// <item>No socket / throttled reconnect — the endpoint is down: Degraded, the rest of the
/// batch fails fast.</item>
/// <item>Any other transport failure (timeout, desync, EOF) — Degraded; the rest of that
/// unit's batch fails fast instead of serialising N timeouts.</item>
/// </list>
/// </summary>
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;
// Fail the unit fast only when nothing came back in time (timeout / desync / socket-level IO):
// a malformed-but-delivered reply (byte-count mismatch) proves the unit responds, so its
// siblings are still tried.
if (ex is IOException or TimeoutException or OperationCanceledException) batch.FailUnit(unit);
return StatusBadCommunicationError;
}
}
/// <summary>Exception 01 Illegal Function / 02 Illegal Data Address / 03 Illegal Data Value — the device refused the request.</summary>
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);
}
// <summary>
// Resolve a reference to the per-slave host key (<c>host:port/unitN</c>) that keys its
// per-host resilience (bulkhead / circuit breaker). Routes through
// <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
// JSON) keys its OWN authored unit — via the tag's optional <c>unitId</c> or the driver
// default — rather than the single-slave fallback. The resolver caches parses, so this adds
// no per-call cost after first use.
// </summary>
/// <inheritdoc />
public string ResolveHost(string fullReference)
{
if (_resolver.TryResolve(fullReference, out var tag))
return BuildSlaveHostName(ResolveUnitId(tag));
// Unknown reference — fall back to driver-instance host (single-slave behaviour).
return HostName;
}
/// <summary>Format a per-slave host string. Multi-slave deployments distinguish breakers by this string.</summary>
private string BuildSlaveHostName(byte unitId) => $"{_options.Host}:{_options.Port}/unit{unitId}";
/// <summary>
/// Deadband filter for the subscribe path. The baseline is keyed on <b>(value, status)</b>, never
/// value alone: a Bad/null publish drops the baseline, and a status change always publishes. A
/// device that comes back after a comms drop usually comes back holding the SAME reading, so the
/// old value-only comparison (<c>|10 − 10| &lt; deadband</c>) swallowed the Good recovery and left
/// the node Bad until the value drifted past the deadband — the #527 trap.
/// </summary>
/// <param name="tagRef">The subscribed reference.</param>
/// <param name="snapshot">The snapshot the poll engine is about to raise.</param>
/// <returns><see langword="true"/> when the snapshot must be published.</returns>
internal bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
{
if (!_resolver.TryResolve(tagRef, out var tag) || tag.Deadband is null) return true;
// Deadband only applies to numeric scalar types — array / Bool / String publishes
// unconditionally. Easier to special-case skip than to enumerate the supported types.
if (tag.ArrayCount.HasValue || tag.DataType is ModbusDataType.Bool or ModbusDataType.BitInRegister or ModbusDataType.String)
return true;
if (snapshot.Value is null || StatusCodeIsBad(snapshot.StatusCode))
{
// A Bad publish always passes AND drops the baseline, so the next Good sample is
// compared against nothing and publishes — whatever value it carries.
_lastPublishedByRef.TryRemove(tagRef, out _);
return true;
}
if (!_lastPublishedByRef.TryGetValue(tagRef, out var prev) || prev.Status != snapshot.StatusCode)
{
// First sample, or a quality transition (Good ↔ Uncertain): passes unconditionally and
// (re)seeds the comparison. The threshold cannot be evaluated across a status change.
_lastPublishedByRef[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
var newD = Convert.ToDouble(snapshot.Value);
var oldD = Convert.ToDouble(prev.Value);
if (Math.Abs(newD - oldD) < tag.Deadband.Value) return false;
_lastPublishedByRef[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
/// <summary>OPC UA severity bits 31..30 = 10 ⇒ Bad.</summary>
private static bool StatusCodeIsBad(uint statusCode) => (statusCode & 0xC0000000u) == 0x80000000u;
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />
public string DriverType => "Modbus";
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always
/// passes a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise
/// lifecycle shape without a config — those keep the constructor-supplied options.</summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
finally
{
_lifecycleGate.Release();
}
}
/// <summary>The gate-held body of <see cref="InitializeAsync"/>.</summary>
private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// 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;
_commsFault = null;
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
}
IModbusTransport? transport = null;
try
{
// Release whatever a PREVIOUS initialize published before building the replacements. All
// 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
// can overlap a still-running attempt, so this must not rely on a teardown having happened.
// Deliberately NOT the shared TeardownAsync(): that also disposes _poll, which tears down
// 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 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
// with, so an operator's edit is discarded while the deployment still seals green. An empty /
// placeholder document (the "{}" some unit tests pass) keeps the constructor-supplied options.
if (HasConfigBody(driverConfigJson))
_options = ModbusDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
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<string, ModbusTagDefinition>(StringComparer.Ordinal);
_skippedTags.BeginRebuild(_options.RawTags.Count);
foreach (var entry in _options.RawTags)
{
if (ModbusTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, _options.Family, _options.MelsecSubFamily, out var def))
table[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
else
{
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
_logger.LogWarning(
"Modbus tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
}
_skippedTags.Commit();
// 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)
{
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<string, byte>(StringComparer.Ordinal));
Volatile.Write(ref _fc23Unsupported, new ConcurrentDictionary<byte, byte>());
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. 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.
//
// #729: each loop's CancellationToken VALUE is snapshotted here, before scheduling, and the
// worker delegate closes over that value — never over the CTS (or the mutable _probeCts /
// _reprobeCts fields). The scheduling token only suppresses a still-queued task; once the
// pool has committed to running the delegate, teardown can complete underneath it (the
// bounded join in StopBackgroundLoopsAsync gives up and disposes the CTS). A delegate that
// then read cts.Token would throw ObjectDisposedException; one that read the field would
// see null or adopt the NEXT initialization's token. A token value stays valid after its
// source is disposed and still reports the cancellation, so the late worker exits cleanly
// on its own generation's cancelled token.
if (_options.Probe.Enabled)
{
var cts = new CancellationTokenSource();
var probeToken = cts.Token;
var probeTransport = transport;
_probeCts = cts;
_probeTask = Task.Run(() => ProbeLoopAsync(generation, probeTransport, probeToken), probeToken);
}
if (_options.AutoProhibitReprobeInterval is not null)
{
var cts = new CancellationTokenSource();
var reprobeToken = cts.Token;
_reprobeCts = cts;
_reprobeTask = Task.Run(() => ReprobeLoopAsync(reprobeToken), reprobeToken);
}
}
transport = null; // published — owned by the driver now
}
catch (Exception ex)
{
// 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;
}
}
/// <summary>Cancels, bounded-joins and disposes the probe and re-probe loops. Idempotent.</summary>
/// <remarks>
/// Limits (#729): the join is bounded by <see cref="BackgroundLoopJoinTimeout"/>, so this does NOT
/// guarantee a loop has finished when it returns — a loop stuck in a transport call that ignores its
/// token, or a worker the pool had committed to but not yet invoked, is abandoned, not awaited. Such
/// a worker holds its own already-cancelled token value (never the CTS or the fields), so it exits on
/// its first token check without faulting. Transport lifetime is NOT bound to the loops: the caller
/// disposes the transport after this returns, so a probe send that ignores its token and outlives the
/// join completes (or faults) against a disposed transport; the loop then sees its cancelled token and
/// returns without a health transition.
/// </remarks>
private async Task StopBackgroundLoopsAsync()
{
var probeCts = _probeCts;
var reprobeCts = _reprobeCts;
var tasks = new[] { _probeTask, _reprobeTask }.Where(t => t is not null).Cast<Task>().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();
}
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// Holds the gate across BOTH halves and calls the ungated cores: taking it once here is what
// makes the shutdown-then-initialize pair atomic. Routing through the public (gated) methods
// instead would both self-deadlock and leave a window between them for a concurrent
// initialize to slip into.
await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await ShutdownCoreAsync().ConfigureAwait(false);
await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
finally
{
_lifecycleGate.Release();
}
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
if (await _lifecycleGate.WaitAsync(LifecycleGateTimeout, cancellationToken).ConfigureAwait(false))
{
try
{
await ShutdownCoreAsync().ConfigureAwait(false);
}
finally
{
_lifecycleGate.Release();
}
}
else
{
_logger.LogWarning(
"Modbus lifecycle gate still held at shutdown; tearing the transport down anyway. Driver={DriverInstanceId}",
_driverInstanceId);
await ShutdownCoreAsync().ConfigureAwait(false);
}
}
/// <summary>The gate-held body of <see cref="ShutdownAsync"/>, shared with
/// <see cref="ReinitializeAsync"/>.</summary>
private async Task ShutdownCoreAsync()
{
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);
WriteLifecycleHealth(generation, new DriverHealth(DriverState.Unknown, lastRead, null));
}
/// <inheritdoc />
public DriverHealth GetHealth() => ReadHealth();
/// <summary>
/// Barrier-protected read of the multi-thread <c>_health</c> field.
/// <c>Volatile.Read</c> guarantees <c>GetHealth()</c> and the in-driver self-reads (the
/// Degraded paths that retain <c>LastSuccessfulRead</c>) observe the most recently
/// published snapshot rather than a per-core cached stale copy.
/// </summary>
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
/// <summary>
/// Barrier-protected publish of a new <c>_health</c> snapshot.
/// </summary>
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
// ---- ITagDiscovery ----
/// <inheritdoc />
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder("Modbus", "Modbus");
// Surface the authored raw tags (mapped into the RawPath → def table at Initialize). The def's
// Name is the RawPath, used for both the browse name and the driver FullName.
foreach (var t in _tagsByRawPath.Values)
{
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
FullName: t.Name,
DriverDataType: MapDataType(t.DataType),
IsArray: t.ArrayCount.HasValue,
ArrayDim: t.ArrayCount.HasValue ? (uint)t.ArrayCount.Value : null,
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: t.WriteIdempotent));
}
return Task.CompletedTask;
}
// ---- IReadable ----
/// <inheritdoc />
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var (generation, transport, table) = CaptureLifecycle();
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
var batch = new BatchOutcome();
// MaxReadGap 0 = coalescing OFF (its pre-lane meaning, kept for every persisted config that authored
// it); the gap-8 default applies only to an absent key.
var coalesced = !_options.DisableReadCoalescing && _options.MaxReadGap > 0
? await ReadCoalescedAsync(transport, table, fullReferences, results, now, batch, cancellationToken).ConfigureAwait(false)
: new HashSet<int>();
for (var i = 0; i < fullReferences.Count; i++)
{
if (coalesced.Contains(i)) continue;
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);
InvalidateWriteCache(fullReferences[i]);
continue;
}
try
{
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, 0u, now, now);
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.
InvalidateWriteCacheIfDiverged(fullReferences[i], value);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, ClassifyFailure(ex, unit, fullReferences[i], batch, isWrite: false), null, now);
InvalidateWriteCache(fullReferences[i]);
}
}
// 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, batch, now);
return results;
}
/// <summary>
/// Captures the generation, transport and tag table a read/write batch runs against, atomically
/// with respect to a lifecycle publish.
/// </summary>
private (int Generation, IModbusTransport Transport, Dictionary<string, ModbusTagDefinition> Table) CaptureLifecycle()
{
lock (_stateLock)
{
var transport = _transport ?? throw new InvalidOperationException("ModbusDriver not initialized");
return (_generation, transport, _tagsByRawPath);
}
}
/// <summary>Runs a codec decode, normalising its failure to <see cref="ModbusDecodeException"/> so the
/// classifier can tell undecodable device data from a transport fault.</summary>
private static object Decode(Func<object> decode, ModbusTagDefinition tag)
{
try { return decode(); }
catch (Exception ex) when (ex is InvalidDataException or OverflowException or ArgumentException or InvalidCastException)
{
throw new ModbusDecodeException($"value of {tag.Name} ({tag.DataType}) could not be decoded: {ex.Message}", ex);
}
}
private async Task<object> ReadOneAsync(IModbusTransport transport, ModbusTagDefinition tag, CancellationToken ct)
{
var arrayCount = tag.ArrayCount ?? 1;
switch (tag.Region)
{
case ModbusRegion.Coils:
case ModbusRegion.DiscreteInputs:
{
// FC01 (Coils) / FC02 (DiscreteInputs). Auto-chunk when array count exceeds the
// coil cap — Modbus spec says ≤ 2000 bits per request; some devices cap lower
// (we trust the caller-provided MaxCoilsPerRead).
var fc = tag.Region == ModbusRegion.Coils ? (byte)0x01 : (byte)0x02;
var cap = _options.MaxCoilsPerRead == 0 ? (ushort)2000 : _options.MaxCoilsPerRead;
var unitId = ResolveUnitId(tag);
var bitmap = arrayCount <= cap
? await ReadBitBlockAsync(transport, unitId, fc, tag.Address, (ushort)arrayCount, ct).ConfigureAwait(false)
: await ReadBitBlockChunkedAsync(transport, unitId, fc, tag.Address, arrayCount, cap, ct).ConfigureAwait(false);
return DecodeBitArray(bitmap, arrayCount, tag.ArrayCount.HasValue);
}
case ModbusRegion.HoldingRegisters:
case ModbusRegion.InputRegisters:
{
var elementRegs = RegisterCount(tag);
var totalRegs = (ushort)(elementRegs * arrayCount);
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
// Auto-chunk when the tag's register span exceeds the caller-configured cap.
// Affects long strings + arrays (FC03/04 > 125 regs is spec-forbidden; DL205 caps
// at 128, Mitsubishi Q caps at 64). Scalar non-string tags max out at 4 regs so
// the cap never triggers for them.
var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
var unitId = ResolveUnitId(tag);
var data = totalRegs <= cap
? await ReadRegisterBlockAsync(transport, unitId, fc, tag.Address, totalRegs, ct).ConfigureAwait(false)
: await ReadRegisterBlockChunkedAsync(transport, unitId, fc, tag.Address, totalRegs, cap, ct).ConfigureAwait(false);
if (!tag.ArrayCount.HasValue)
return Decode(() => DecodeRegister(data, tag), tag);
return Decode(() => DecodeRegisterArray(data, tag, elementRegs, arrayCount), tag);
}
default:
throw new InvalidOperationException($"Unknown region {tag.Region}");
}
}
/// <summary>
/// Decode an FC01/FC02 coil-bitmap response into either a single bool (scalar tag) or a
/// bool[] of <paramref name="count"/> elements (array tag). Modbus packs coils LSB-first
/// within each byte, ascending address across bytes.
/// </summary>
private static object DecodeBitArray(ReadOnlySpan<byte> bitmap, int count, bool isArray)
{
if (bitmap.IsEmpty)
throw new InvalidDataException("Modbus bit response produced an empty bitmap — cannot decode coil value");
if (!isArray) return (bitmap[0] & 0x01) == 1;
var result = new bool[count];
for (var i = 0; i < count; i++)
result[i] = ((bitmap[i / 8] >> (i % 8)) & 0x01) == 1;
return result;
}
/// <summary>
/// Decode an array of register-backed values from a contiguous block. Each element
/// occupies <paramref name="elementRegs"/> registers and is decoded with the same
/// codec the scalar path uses, sliced from its position in the block.
/// </summary>
private static object DecodeRegisterArray(byte[] data, ModbusTagDefinition tag, int elementRegs, int count)
{
var elementBytes = elementRegs * 2;
// Element type drives the array CLR type. Boxed into Array so the Read pipeline can
// surface it directly without a per-call type-switch on the caller side.
switch (tag.DataType)
{
case ModbusDataType.Int16:
{
var arr = new short[count];
for (var i = 0; i < count; i++)
arr[i] = (short)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
return arr;
}
case ModbusDataType.UInt16:
{
var arr = new ushort[count];
for (var i = 0; i < count; i++)
arr[i] = (ushort)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
return arr;
}
case ModbusDataType.Int32:
case ModbusDataType.Bcd16:
case ModbusDataType.Bcd32:
{
var arr = new int[count];
for (var i = 0; i < count; i++)
arr[i] = (int)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
return arr;
}
case ModbusDataType.UInt32:
{
var arr = new uint[count];
for (var i = 0; i < count; i++)
arr[i] = (uint)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
return arr;
}
case ModbusDataType.Int64:
{
var arr = new long[count];
for (var i = 0; i < count; i++)
arr[i] = (long)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
return arr;
}
case ModbusDataType.UInt64:
{
var arr = new ulong[count];
for (var i = 0; i < count; i++)
arr[i] = (ulong)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
return arr;
}
case ModbusDataType.Float32:
{
var arr = new float[count];
for (var i = 0; i < count; i++)
arr[i] = (float)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
return arr;
}
case ModbusDataType.Float64:
{
var arr = new double[count];
for (var i = 0; i < count; i++)
arr[i] = (double)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
return arr;
}
case ModbusDataType.String:
{
// Each element occupies elementRegs registers = elementBytes bytes, encoding
// StringLength chars packed high-byte-first or low-byte-first per tag.StringByteOrder.
// DecodeRegister handles the per-element slice identically to the scalar path.
var arr = new string[count];
for (var i = 0; i < count; i++)
arr[i] = (string)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
return arr;
}
case ModbusDataType.BitInRegister:
{
// Each element is 1 holding register (2 bytes). The same BitIndex is applied to
// every element. DecodeRegister returns bool for BitInRegister scalar slices.
var arr = new bool[count];
for (var i = 0; i < count; i++)
arr[i] = (bool)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
return arr;
}
default:
throw new InvalidOperationException(
$"Array decode not supported for {tag.DataType} (use scalar tags or split by element)");
}
}
/// <summary>Resolve the UnitId for a tag — per-tag override or driver-level fallback.</summary>
private byte ResolveUnitId(ModbusTagDefinition tag) => tag.UnitId ?? _options.UnitId;
private bool RangeIsAutoProhibited(byte unit, ModbusRegion region, ushort start, ushort end)
{
lock (_autoProhibitedLock)
{
foreach (var p in _autoProhibited.Keys)
{
// A candidate (start..end) range is prohibited if it overlaps any recorded
// failure. Overlap rule: max-start ≤ min-end. We don't try to be smart about
// partial overlap — once a range fails, any superset of it is also untrusted.
if (p.Unit != unit || p.Region != region) continue;
if (Math.Max(start, p.Start) <= Math.Min(end, p.End)) return true;
}
return false;
}
}
private void RecordAutoProhibition(byte unit, ModbusRegion region, ushort start, ushort end)
{
bool isNew;
lock (_autoProhibitedLock)
{
// Multi-register prohibitions enter the bisection workflow on the next re-probe;
// single-register prohibitions are already minimal and skip bisection.
isNew = !_autoProhibited.ContainsKey((unit, region, start, end));
_autoProhibited[(unit, region, start, end)] = new ProhibitionState
{
LastProbedUtc = DateTime.UtcNow,
SplitPending = end > start,
};
}
if (isNew)
_logger.LogWarning(
"Modbus coalesced read failed; auto-prohibited range recorded. Driver={DriverInstanceId} Unit={Unit} Region={Region} Start={Start} End={End} Span={Span}",
_driverInstanceId, unit, region, start, end, end - start + 1);
}
/// <summary>
/// Info log when a re-probe clears a prohibition. Operators see recovery
/// events without having to poll <see cref="GetAutoProhibitedRanges"/>.
/// </summary>
private void LogProhibitionCleared(byte unit, ModbusRegion region, ushort start, ushort end) =>
_logger.LogInformation(
"Modbus auto-prohibition cleared by re-probe. Driver={DriverInstanceId} Unit={Unit} Region={Region} Start={Start} End={End}",
_driverInstanceId, unit, region, start, end);
/// <summary>
/// Operator-visible snapshot of every range the planner has learned to read
/// individually. Exposed through the driver-diagnostics surface; consumers (Admin UI,
/// log-aggregation, dashboards) call this to show what's been auto-isolated. Populated
/// on coalesced-read failure, narrowed by bisection, cleared by the
/// re-probe loop when ranges become healthy again.
/// </summary>
/// <returns>The current set of auto-prohibited register ranges.</returns>
public IReadOnlyList<ModbusAutoProhibition> GetAutoProhibitedRanges()
{
lock (_autoProhibitedLock)
return _autoProhibited
.Select(kv => new ModbusAutoProhibition(
kv.Key.Unit, kv.Key.Region, kv.Key.Start, kv.Key.End,
kv.Value.LastProbedUtc, kv.Value.SplitPending))
.ToArray();
}
/// <summary>Test/diagnostic accessor — returns the current auto-prohibited range count.</summary>
internal int AutoProhibitedRangeCount
{
get { lock (_autoProhibitedLock) return _autoProhibited.Count; }
}
/// <summary>
/// Periodic re-probe loop, augmented with bisection-style narrowing.
/// Each tick processes every prohibition: split-pending multi-register ranges get
/// bisected (try left + right halves; replace with whichever halves still fail),
/// single-register or non-split-pending ranges get a straight re-probe. Lives for
/// the driver lifetime; cancelled by <c>ShutdownAsync</c>.
/// </summary>
private async Task ReprobeLoopAsync(CancellationToken ct)
{
var interval = _options.AutoProhibitReprobeInterval!.Value;
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
try { await RunReprobeOnceForTestAsync(ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch (ObjectDisposedException) when (ct.IsCancellationRequested)
{
return;
}
}
}
/// <summary>
/// One re-probe pass. Public-but-internal so tests can drive it synchronously rather
/// than wait on the background timer. Iterates a snapshot of the prohibition set; for
/// each entry decides between bisection (multi-register + SplitPending) or straight
/// retry (single-register or already-narrowed).
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
internal async Task RunReprobeOnceForTestAsync(CancellationToken ct)
{
var transport = _transport ?? throw new InvalidOperationException("Transport not connected");
((byte Unit, ModbusRegion Region, ushort Start, ushort End) Key, bool SplitPending)[] candidates;
lock (_autoProhibitedLock)
candidates = _autoProhibited
.Select(kv => (Key: kv.Key, SplitPending: kv.Value.SplitPending))
.ToArray();
foreach (var (key, splitPending) in candidates)
{
if (ct.IsCancellationRequested) return;
if (splitPending && key.End > key.Start)
await BisectAndReprobeAsync(transport, key, ct).ConfigureAwait(false);
else
await StraightReprobeAsync(transport, key, ct).ConfigureAwait(false);
}
}
private async Task StraightReprobeAsync(IModbusTransport transport,
(byte Unit, ModbusRegion Region, ushort Start, ushort End) key, CancellationToken ct)
{
var qty = (ushort)(key.End - key.Start + 1);
try
{
_ = await ReadRangeAsync(transport, key.Unit, key.Region, key.Start, qty, ct).ConfigureAwait(false);
lock (_autoProhibitedLock) _autoProhibited.Remove(key);
LogProhibitionCleared(key.Unit, key.Region, key.Start, key.End);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch
{
lock (_autoProhibitedLock)
if (_autoProhibited.TryGetValue(key, out var st)) st.LastProbedUtc = DateTime.UtcNow;
}
}
/// <summary>
/// Bisect a multi-register prohibition. Removes the parent entry and re-adds
/// whichever halves still fail. Over multiple re-probe ticks the prohibition narrows
/// log2(span) times until it pinpoints the actual protected register(s).
/// </summary>
private async Task BisectAndReprobeAsync(IModbusTransport transport,
(byte Unit, ModbusRegion Region, ushort Start, ushort End) key, CancellationToken ct)
{
var mid = (ushort)((key.Start + key.End) / 2);
var leftEnd = mid;
var rightStart = (ushort)(mid + 1);
var leftFailed = await ProbeFailsAsync(transport, key.Region, key.Unit, key.Start, leftEnd, ct).ConfigureAwait(false);
var rightFailed = await ProbeFailsAsync(transport, key.Region, key.Unit, rightStart, key.End, ct).ConfigureAwait(false);
lock (_autoProhibitedLock)
{
_autoProhibited.Remove(key);
if (leftFailed)
{
_autoProhibited[(key.Unit, key.Region, key.Start, leftEnd)] = new ProhibitionState
{
LastProbedUtc = DateTime.UtcNow,
SplitPending = leftEnd > key.Start,
};
}
if (rightFailed)
{
_autoProhibited[(key.Unit, key.Region, rightStart, key.End)] = new ProhibitionState
{
LastProbedUtc = DateTime.UtcNow,
SplitPending = key.End > rightStart,
};
}
// Both halves succeeded → entry is just removed. The parent prohibition is gone
// and the next normal scan can re-coalesce across the whole original range.
}
if (!leftFailed && !rightFailed)
LogProhibitionCleared(key.Unit, key.Region, key.Start, key.End);
else
{
if (!leftFailed)
LogProhibitionCleared(key.Unit, key.Region, key.Start, leftEnd);
if (!rightFailed)
LogProhibitionCleared(key.Unit, key.Region, rightStart, key.End);
}
}
private async Task<bool> ProbeFailsAsync(IModbusTransport transport, ModbusRegion region, byte unit,
ushort start, ushort end, CancellationToken ct)
{
var qty = (ushort)(end - start + 1);
try
{
_ = await ReadRangeAsync(transport, unit, region, start, qty, ct).ConfigureAwait(false);
return false;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch { return true; }
}
/// <summary>
/// Block-read coalescing planner. Groups eligible tags by (UnitId, Region), sorts by start address,
/// and merges adjacent / near-adjacent tags (gap ≤ <see cref="ModbusDriverOptions.MaxReadGap"/>, 0 =
/// adjacent or overlapping only) into one read per block: FC03/FC04 for the register regions (scalar
/// numeric, <c>String</c> and <c>BitInRegister</c> tags — several bit tags on ONE status word fold into
/// a single read of it), FC01/FC02 for Coils / DiscreteInputs (gap counted in bits). Per block: emit
/// one PDU, slice the response back into per-tag values, populate <paramref name="results"/> and the
/// WriteOnChangeOnly cache. Returns the set of <paramref name="fullReferences"/> indices the planner
/// handled — the caller falls back to the per-tag path for the rest (arrays, lone tags, prohibited,
/// unknown, refused ranges).
/// </summary>
private async Task<HashSet<int>> ReadCoalescedAsync(
IModbusTransport transport,
Dictionary<string, ModbusTagDefinition> table,
IReadOnlyList<string> fullReferences,
DataValueSnapshot[] results,
DateTime timestamp,
BatchOutcome batch,
CancellationToken ct)
{
// Eligible: known scalar tag, not CoalesceProhibited, with a region/type pair the codec can slice.
var eligible = new List<(int Index, ModbusTagDefinition Tag)>();
for (var i = 0; i < fullReferences.Count; i++)
{
if (!table.TryGetValue(fullReferences[i], out var tag)) continue;
if (tag.CoalesceProhibited) continue;
// Defensive: the factory refuses incompatible pairs, but the planner's register math must never
// throw for a tag that slipped through — one bad tag would otherwise fail the whole batch.
if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(tag.Region, tag.DataType)) continue;
if (tag.ArrayCount.HasValue) continue;
eligible.Add((i, tag));
}
if (eligible.Count == 0) return new HashSet<int>();
var handled = new HashSet<int>();
// Group by (UnitId, Region) — coalescing across slaves or regions is unsafe.
foreach (var group in eligible.GroupBy(e => (Unit: ResolveUnitId(e.Tag), e.Tag.Region)))
{
var isBits = IsBitRegion(group.Key.Region);
var cap = isBits
? (_options.MaxCoilsPerRead == 0 ? 2000 : _options.MaxCoilsPerRead)
: (_options.MaxRegistersPerRead == 0 ? 125 : _options.MaxRegistersPerRead);
var sorted = group.OrderBy(e => e.Tag.Address).ToList();
// Build merged blocks. A "block" is (start, lastEnd, members[]) where lastEnd is the inclusive
// end of the widest member span seen so far. A new tag joins the block if its start ≤ lastEnd +
// 1 + MaxReadGap AND the resulting span ≤ cap AND the span crosses no auto-prohibited range.
var blocks = new List<(int Start, int End, List<(int Index, ModbusTagDefinition Tag)> Members)>();
foreach (var (idx, tag) in sorted)
{
var tagStart = (int)tag.Address;
var tagEnd = tagStart + (isBits ? 1 : RegisterCount(tag)) - 1;
if (tagEnd > ushort.MaxValue || tagEnd - tagStart + 1 > cap) continue; // per-tag path chunks / refuses it
if (blocks.Count > 0)
{
var last = blocks[^1];
var gap = tagStart - last.End - 1;
var newEnd = Math.Max(tagEnd, last.End);
var newSpan = newEnd - last.Start + 1;
var crossesProhibition = RangeIsAutoProhibited(group.Key.Unit, group.Key.Region, (ushort)last.Start, (ushort)newEnd);
if (gap <= _options.MaxReadGap && newSpan <= cap && !crossesProhibition)
{
last.Members.Add((idx, tag));
blocks[^1] = (last.Start, newEnd, last.Members);
continue;
}
}
blocks.Add((tagStart, tagEnd, new List<(int, ModbusTagDefinition)> { (idx, tag) }));
}
// Issue one PDU per block. On a device REFUSAL (illegal data address / protected register),
// record the range as auto-prohibited, leave the member indices UNhandled, and let the per-tag
// fallback in ReadAsync read each surviving address individually. On a transport-level failure
// (timeout / socket drop) mark members Bad and short-circuit the per-tag fallback (hitting the
// dead socket again won't help). Bisection narrows the prohibition over subsequent re-probe ticks.
foreach (var block in blocks)
{
if (block.Members.Count == 1)
{
// Lone tag — let the per-tag path handle it for symmetry with WriteOnChange
// cache invalidation. Costs nothing because one tag = one PDU either way.
continue;
}
var start = (ushort)block.Start;
var qty = (ushort)(block.End - block.Start + 1);
if (batch.IsFailedFast(group.Key.Unit))
{
foreach (var (idx, _) in block.Members)
{
results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, timestamp);
InvalidateWriteCache(fullReferences[idx]);
handled.Add(idx);
}
continue;
}
byte[] data;
try
{
data = await ReadRangeAsync(transport, group.Key.Unit, group.Key.Region, start, qty, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (ModbusException mex) when (IsRefusalCode(mex.ExceptionCode))
{
// The device refused the merged range (a protected hole inside it): record the range
// as auto-prohibited and leave the members UNhandled so the per-tag fallback reads each
// surviving address individually. The device answered, so health is not degraded.
batch.Answered = true;
RecordAutoProhibition(group.Key.Unit, group.Key.Region, start, (ushort)block.End);
continue;
}
catch (Exception ex)
{
// Communication failures (timeout, socket drop) and non-refusal exception codes aren't
// a structural reason to prohibit the range — the same coalesced read might succeed
// once the transport recovers. Mark members Bad for this scan but don't auto-prohibit
// and don't deflect to per-tag fallback (which would just hit the same dead socket).
var status = ClassifyFailure(ex, group.Key.Unit, fullReferences[block.Members[0].Index], batch, isWrite: false);
foreach (var (idx, _) in block.Members)
{
results[idx] = new DataValueSnapshot(null, status, null, timestamp);
InvalidateWriteCache(fullReferences[idx]);
handled.Add(idx);
}
continue;
}
batch.Answered = true;
foreach (var (idx, tag) in block.Members)
{
handled.Add(idx);
try
{
object value;
if (isBits)
{
var bit = tag.Address - block.Start;
value = ((data[bit / 8] >> (bit % 8)) & 0x01) == 1;
}
else
{
var sliceOffsetBytes = (tag.Address - block.Start) * 2;
var sliceLenBytes = RegisterCount(tag) * 2;
value = Decode(() => DecodeRegister(data.AsSpan(sliceOffsetBytes, sliceLenBytes), tag), tag);
}
results[idx] = new DataValueSnapshot(value, 0u, timestamp, timestamp);
InvalidateWriteCacheIfDiverged(fullReferences[idx], value);
}
catch (ModbusDecodeException dex)
{
// One undecodable member never fails its neighbours.
results[idx] = new DataValueSnapshot(null,
ClassifyFailure(dex, group.Key.Unit, fullReferences[idx], batch, isWrite: false), null, timestamp);
}
}
}
}
return handled;
}
private static bool IsBitRegion(ModbusRegion region) => region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs;
private static byte ReadFunctionCode(ModbusRegion region) => region switch
{
ModbusRegion.Coils => 0x01,
ModbusRegion.DiscreteInputs => 0x02,
ModbusRegion.HoldingRegisters => 0x03,
_ => 0x04,
};
/// <summary>Reads one contiguous range of the region with the matching function code — registers as
/// big-endian bytes, bits as an LSB-first bitmap.</summary>
private Task<byte[]> ReadRangeAsync(
IModbusTransport transport, byte unit, ModbusRegion region, ushort start, ushort qty, CancellationToken ct)
=> IsBitRegion(region)
? ReadBitBlockAsync(transport, unit, ReadFunctionCode(region), start, qty, ct)
: ReadRegisterBlockAsync(transport, unit, ReadFunctionCode(region), start, qty, ct);
/// <summary>
/// A failed read leaves the device's value UNKNOWN (it may have rebooted holding anything), so the
/// WriteOnChangeOnly suppression for that tag is dropped — otherwise re-asserting the same setpoint
/// after an outage would be swallowed, the write-side twin of the value-only recovery dedup trap.
/// </summary>
private void InvalidateWriteCache(string fullRef)
{
if (!_options.WriteOnChangeOnly) return;
lock (_lastWrittenLock) _lastWrittenByRef.Remove(fullRef);
}
private void InvalidateWriteCacheIfDiverged(string fullRef, object value)
{
if (!_options.WriteOnChangeOnly) return;
lock (_lastWrittenLock)
{
if (_lastWrittenByRef.TryGetValue(fullRef, out var prev) && !Equals(prev, value))
_lastWrittenByRef.Remove(fullRef);
}
}
private async Task<byte[]> ReadRegisterBlockAsync(
IModbusTransport transport, byte unitId, byte fc, ushort address, ushort quantity, CancellationToken ct)
{
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
var resp = await transport.SendAsync(unitId, pdu, ct).ConfigureAwait(false);
// resp = [fc][byte-count][data...] — validate before indexing to surface a clean error
// rather than an IndexOutOfRangeException when a device returns a truncated PDU.
if (resp.Length < 2)
throw new InvalidDataException(
$"Modbus register response too short: expected at least 2 bytes (fc+bytecount), got {resp.Length}");
if (resp.Length < 2 + resp[1])
throw new InvalidDataException(
$"Modbus register response truncated: byte-count field declares {resp[1]} bytes but only {resp.Length - 2} available");
var expectedByteCount = quantity * 2;
if (resp[1] != expectedByteCount)
throw new InvalidDataException(
$"Modbus register response byte-count mismatch: requested {quantity} registers ({expectedByteCount} bytes), got {resp[1]} bytes");
var data = new byte[resp[1]];
Buffer.BlockCopy(resp, 2, data, 0, resp[1]);
return data;
}
private async Task<byte[]> ReadBitBlockAsync(
IModbusTransport transport, byte unitId, byte fc, ushort address, ushort qty, CancellationToken ct)
{
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
(byte)(qty >> 8), (byte)(qty & 0xFF) };
var resp = await transport.SendAsync(unitId, pdu, ct).ConfigureAwait(false);
if (resp.Length < 2)
throw new InvalidDataException(
$"Modbus bit response too short: expected at least 2 bytes (fc+bytecount), got {resp.Length}");
if (resp.Length < 2 + resp[1])
throw new InvalidDataException(
$"Modbus bit response truncated: byte-count field declares {resp[1]} bytes but only {resp.Length - 2} available");
var expectedByteCount = (qty + 7) / 8;
if (resp[1] < expectedByteCount)
throw new InvalidDataException(
$"Modbus bit response byte-count mismatch: requested {qty} bits ({expectedByteCount} bytes), got {resp[1]} bytes");
var bitmap = new byte[resp[1]];
Buffer.BlockCopy(resp, 2, bitmap, 0, resp[1]);
return bitmap;
}
/// <summary>
/// Auto-chunk coil-array reads above MaxCoilsPerRead. Reassembles per-chunk bitmaps into
/// one logical bitmap byte array sized for the full <paramref name="totalBits"/>; the
/// downstream <see cref="DecodeBitArray"/> walks bits LSB-first the same way it would
/// for a single-chunk response.
/// </summary>
private async Task<byte[]> ReadBitBlockChunkedAsync(
IModbusTransport transport, byte unitId, byte fc, ushort address, int totalBits, ushort cap, CancellationToken ct)
{
var assembled = new byte[(totalBits + 7) / 8];
var done = 0;
while (done < totalBits)
{
var chunk = (ushort)Math.Min(cap, totalBits - done);
var chunkBitmap = await ReadBitBlockAsync(transport, unitId, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
// Re-pack per-chunk LSB-first bits into the assembled bitmap at the right offset.
for (var i = 0; i < chunk; i++)
{
if (((chunkBitmap[i / 8] >> (i % 8)) & 0x01) == 0) continue;
var dest = done + i;
assembled[dest / 8] |= (byte)(1 << (dest % 8));
}
done += chunk;
}
return assembled;
}
private async Task<byte[]> ReadRegisterBlockChunkedAsync(
IModbusTransport transport, byte unitId, byte fc, ushort address, ushort totalRegs, ushort cap, CancellationToken ct)
{
var assembled = new byte[totalRegs * 2];
ushort done = 0;
while (done < totalRegs)
{
var chunk = (ushort)Math.Min(cap, totalRegs - done);
var chunkBytes = await ReadRegisterBlockAsync(transport, unitId, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
Buffer.BlockCopy(chunkBytes, 0, assembled, done * 2, chunkBytes.Length);
done += chunk;
}
return assembled;
}
// ---- IWritable ----
/// <inheritdoc />
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
var (generation, transport, table) = CaptureLifecycle();
var results = new WriteResult[writes.Count];
var batch = new BatchOutcome();
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!table.TryGetValue(w.FullReference, out var tag))
{
results[i] = new WriteResult(StatusBadNodeIdUnknown);
continue;
}
if (!tag.Writable || tag.Region is ModbusRegion.DiscreteInputs or ModbusRegion.InputRegisters)
{
results[i] = new WriteResult(StatusBadNotWritable);
continue;
}
if (_options.WriteOnChangeOnly && IsRedundantWrite(w.FullReference, w.Value))
{
results[i] = new WriteResult(0u);
continue;
}
var unit = ResolveUnitId(tag);
if (batch.IsFailedFast(unit))
{
results[i] = new WriteResult(StatusBadCommunicationError);
continue;
}
try
{
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(0u);
batch.Answered = true;
if (_options.WriteOnChangeOnly)
lock (_lastWrittenLock) _lastWrittenByRef[w.FullReference] = w.Value;
}
catch (Exception ex)
{
results[i] = new WriteResult(MapWriteFailure(ex, unit, w.FullReference, batch, cancellationToken));
// A failed write may or may not have landed — the device value is unknown now.
InvalidateWriteCache(w.FullReference);
}
}
// Write outcome feeds the same single health slot as reads: a comms failure degrades (LastSuccessfulRead
// preserved); a good write or a device refusal proves the device answered and clears it. Generation-checked.
ReportOutcome(generation, batch, DateTime.UtcNow);
return results;
}
/// <summary>
/// Write-status mapping (review 07 H7). Failures raised BEFORE the wire — a value that does not fit
/// (<c>BadOutOfRange</c>), cannot convert (<c>BadTypeMismatch</c>) or is not supported
/// (<c>BadNotSupported</c>) — never touch health. Device answers go through
/// <see cref="ClassifyFailure"/> (refusal / device fault / comms fault). Transport failures —
/// desync, timeout, socket, EOF — are <c>BadCommunicationError</c>, no longer
/// <c>BadInternalError</c>; only a genuinely unexpected exception still is.
/// </summary>
private uint MapWriteFailure(Exception ex, byte unit, string tagRef, BatchOutcome batch, CancellationToken ct)
{
switch (ex)
{
case OperationCanceledException when ct.IsCancellationRequested:
// The caller's write deadline fired (e.g. waiting on the single-flight gate). The device
// said nothing, so health is untouched.
return StatusBadTimeout;
case ModbusWriteRefusedException refused:
return refused.StatusCode;
case OverflowException or ArgumentOutOfRangeException:
return StatusBadOutOfRange;
case FormatException or InvalidCastException or ArgumentException:
return StatusBadTypeMismatch;
case NotSupportedException:
return StatusBadNotSupported;
case ModbusException or ModbusDecodeException or IOException or InvalidDataException
or System.Net.Sockets.SocketException or ObjectDisposedException or TimeoutException
or OperationCanceledException:
return ClassifyFailure(ex, unit, tagRef, batch, isWrite: true);
default:
_logger.LogWarning(ex, "Modbus write to {RawPath} failed unexpectedly. Driver={DriverInstanceId}", tagRef, _driverInstanceId);
return StatusBadInternalError;
}
}
private bool IsRedundantWrite(string tagRef, object? value)
{
lock (_lastWrittenLock)
{
if (!_lastWrittenByRef.TryGetValue(tagRef, out var prev)) return false;
// Object.Equals handles boxed-numeric equality (5 == 5 even if one was short and
// one int through boxing). For arrays we deliberately don't suppress — equality
// semantics on arrays are reference-only so the cache miss is the safer answer.
if (prev is null || value is null) return Equals(prev, value);
if (prev is Array || value is Array) return false;
return prev.Equals(value);
}
}
// BitInRegister writes need a read-modify-write against the full holding register. The
// per-register lock (declared at the top of the class) keeps concurrent bit-write callers
// from stomping on each other — Write bit 0 and Write bit 5 targeting the same register
// can arrive on separate subscriber threads, and without serialising the RMW the
// second-to-commit value wins + the first bit update is lost.
private SemaphoreSlim GetRmwLock(ushort address) =>
_rmwLocks.GetOrAdd(address, _ => new SemaphoreSlim(1, 1));
private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
{
// BitInRegister → RMW dispatch ahead of the normal encode path so the lock + read-modify-
// write sequence doesn't hit EncodeRegister's defensive throw.
if (tag.DataType == ModbusDataType.BitInRegister &&
tag.Region is ModbusRegion.HoldingRegisters)
{
if (tag.ArrayCount.HasValue)
await WriteBitInRegisterArrayAsync(transport, tag, value, ct).ConfigureAwait(false);
else
await WriteBitInRegisterAsync(transport, tag, value, ct).ConfigureAwait(false);
return;
}
switch (tag.Region)
{
case ModbusRegion.Coils:
{
if (!tag.ArrayCount.HasValue && !_options.UseFC15ForSingleCoilWrites)
{
var on = Convert.ToBoolean(value);
var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
on ? (byte)0xFF : (byte)0x00, 0x00 };
await transport.SendAsync(ResolveUnitId(tag), pdu, ct).ConfigureAwait(false);
return;
}
// FC15 path: either an explicit array, or UseFC15ForSingleCoilWrites=true forced
// it for a scalar (synthesise a 1-element bool[] from the scalar value).
var arrayLen = tag.ArrayCount ?? 1;
if (!tag.ArrayCount.HasValue)
value = new[] { Convert.ToBoolean(value) };
// FC15 — Write Multiple Coils. Pack the bool[] into LSB-first bitmap.
var values = ToBoolArray(value, arrayLen, tag.Name);
var byteCount = (values.Length + 7) / 8;
var bitmap = new byte[byteCount];
for (var i = 0; i < values.Length; i++)
if (values[i]) bitmap[i / 8] |= (byte)(1 << (i % 8));
var qty = (ushort)values.Length;
// [fc][addr:2][qty:2][byteCount] + data: exactly 6 + byteCount (no trailing byte past the declared data).
var pdu15 = new byte[6 + byteCount];
pdu15[0] = 0x0F;
pdu15[1] = (byte)(tag.Address >> 8); pdu15[2] = (byte)(tag.Address & 0xFF);
pdu15[3] = (byte)(qty >> 8); pdu15[4] = (byte)(qty & 0xFF);
pdu15[5] = (byte)byteCount;
Buffer.BlockCopy(bitmap, 0, pdu15, 6, byteCount);
await transport.SendAsync(ResolveUnitId(tag), pdu15, ct).ConfigureAwait(false);
return;
}
case ModbusRegion.HoldingRegisters:
{
var bytes = tag.ArrayCount.HasValue
? EncodeRegisterArray(value, tag)
: EncodeRegister(value, tag);
if (bytes.Length == 2 && !tag.ArrayCount.HasValue && !_options.UseFC16ForSingleRegisterWrites)
{
// FC06 fast-path for single-register scalar writes only. Arrays always use
// FC16 even when the array is one element wide, because the encoder shape
// may need it. UseFC16ForSingleRegisterWrites=true forces FC16 even here for
// PLCs that only accept the multi-write codes.
var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
bytes[0], bytes[1] };
await transport.SendAsync(ResolveUnitId(tag), pdu, ct).ConfigureAwait(false);
}
else
{
// FC 16 (Write Multiple Registers) for 32-bit / 64-bit / array / string types.
var qty = (ushort)(bytes.Length / 2);
var writeCap = _options.MaxRegistersPerWrite == 0 ? (ushort)123 : _options.MaxRegistersPerWrite;
if (qty > writeCap)
throw new ModbusWriteRefusedException(StatusBadOutOfRange,
$"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
$"Split the tag (e.g. shorter StringLength or smaller ArrayCount) — partial FC16 chunks would lose atomicity.");
if (await TryWriteViaFc23Async(transport, tag, bytes, qty, ct).ConfigureAwait(false)) return;
// [fc][addr:2][qty:2][byteCount] + data: exactly 6 + byteCount.
var pdu = new byte[6 + bytes.Length];
pdu[0] = 0x10;
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
pdu[5] = (byte)bytes.Length;
Buffer.BlockCopy(bytes, 0, pdu, 6, bytes.Length);
await transport.SendAsync(ResolveUnitId(tag), pdu, ct).ConfigureAwait(false);
}
return;
}
default:
throw new ModbusWriteRefusedException(StatusBadNotWritable, $"Writes not supported for region {tag.Region}");
}
}
/// <summary>FC23's spec ceiling on the write quantity (0x79); larger multi-register writes use FC16.</summary>
private const ushort Fc23MaxWriteRegisters = 121;
/// <summary>
/// Units that refused FC23 (exception 01) or failed it at the transport; their multi-register writes
/// use FC16 for the rest of this driver generation. Replaced (never cleared in place) on re-init.
/// </summary>
private ConcurrentDictionary<byte, byte> _fc23Unsupported = new();
/// <summary>
/// Multi-register write via FC23 (Read/Write Multiple Registers): the device writes the block and
/// returns the SAME block read back in one transaction, so the write is confirmed by the device's own
/// post-write registers. OPT-IN via <see cref="ModbusDriverOptions.UseFC23ForMultiRegisterWrites"/>,
/// and <see cref="ModbusDriverOptions.DisableFC23"/> is the hard kill-switch. A device that does
/// not implement FC23 (MELSEC QJ71MT91, S7 Modbus servers — exception 01) falls back to FC16 for this
/// write and every later one on that unit, which is safe because an Illegal Function wrote nothing.
/// A transport failure on FC23 is reported (its outcome is unknown, so it is not retried as FC16) and
/// also moves the unit to FC16, so a gateway that cannot frame FC23 costs one failed write, not all.
/// A read-back that differs from what was written (PLC logic overwrote it in the same scan, or the
/// device clamped it) is logged once per tag; the write itself was accepted.
/// </summary>
/// <returns><see langword="true"/> when FC23 performed the write; <see langword="false"/> to use FC16.</returns>
private async Task<bool> TryWriteViaFc23Async(
IModbusTransport transport, ModbusTagDefinition tag, byte[] bytes, ushort qty, CancellationToken ct)
{
var unit = ResolveUnitId(tag);
var unsupported = Volatile.Read(ref _fc23Unsupported);
// Only genuinely multi-register writes: a single register forced onto FC16 (UseFC16ForSingleRegisterWrites)
// stays on the function code the operator named.
if (!_options.UseFC23ForMultiRegisterWrites || _options.DisableFC23
|| qty < 2 || qty > Fc23MaxWriteRegisters || unsupported.ContainsKey(unit)) return false;
// [fc][readAddr:2][readQty:2][writeAddr:2][writeQty:2][writeByteCount] + data: exactly 10 + byteCount.
var pdu = new byte[10 + bytes.Length];
pdu[0] = 0x17;
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF); // read start
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF); // read quantity
pdu[5] = (byte)(tag.Address >> 8); pdu[6] = (byte)(tag.Address & 0xFF); // write start
pdu[7] = (byte)(qty >> 8); pdu[8] = (byte)(qty & 0xFF); // write quantity
pdu[9] = (byte)bytes.Length;
Buffer.BlockCopy(bytes, 0, pdu, 10, bytes.Length);
byte[] resp;
try
{
resp = await transport.SendAsync(unit, pdu, ct).ConfigureAwait(false);
}
catch (ModbusException mex) when (mex.ExceptionCode == 0x01)
{
if (unsupported.TryAdd(unit, 0))
_logger.LogInformation(
"Modbus unit {Unit} does not implement FC23 (Illegal Function); multi-register writes use FC16 until the driver is re-initialized. Driver={DriverInstanceId}",
unit, _driverInstanceId);
return false;
}
catch (Exception ex) when (ex is not ModbusException && !(ex is OperationCanceledException && ct.IsCancellationRequested))
{
if (unsupported.TryAdd(unit, 0))
_logger.LogWarning(ex,
"Modbus FC23 write to unit {Unit} failed at the transport; later multi-register writes to it use FC16 until the driver is re-initialized (a redeploy or reconnect restores FC23). Driver={DriverInstanceId}",
unit, _driverInstanceId);
throw;
}
// resp = [0x17][byteCount][read-back registers…]
if (resp.Length < 2 || resp[1] != bytes.Length || resp.Length < 2 + resp[1])
throw new InvalidDataException(
$"Modbus FC23 response for {tag.Name} malformed: expected {bytes.Length} read-back bytes, got {(resp.Length < 2 ? -1 : resp[1])}");
if (!resp.AsSpan(2, bytes.Length).SequenceEqual(bytes)
&& Volatile.Read(ref _refusalWarned).TryAdd("fc23:" + tag.Name, 0))
_logger.LogWarning(
"Modbus FC23 read-back of {RawPath} differs from the value written (device logic or clamping changed it); the write was accepted. Driver={DriverInstanceId}",
tag.Name, _driverInstanceId);
return true;
}
/// <summary>
/// Encode an array-typed write value into a contiguous byte block by encoding each
/// element with the scalar codec. Caller submits IList / Array of the element CLR type.
/// </summary>
private static byte[] EncodeRegisterArray(object? value, ModbusTagDefinition tag)
{
var count = tag.ArrayCount!.Value;
if (value is not System.Collections.IList list)
throw new ModbusWriteRefusedException(StatusBadTypeMismatch,
$"Array write to {tag.Name} expects an IList of length {count}; got {value?.GetType().Name ?? "null"}");
if (list.Count != count)
throw new ModbusWriteRefusedException(StatusBadOutOfRange,
$"Array write to {tag.Name} expects {count} elements; got {list.Count}");
var elementBytes = ElementByteCount(tag);
var result = new byte[count * elementBytes];
for (var i = 0; i < count; i++)
{
var element = list[i];
var encoded = EncodeRegister(element, tag);
if (encoded.Length != elementBytes)
throw new InvalidOperationException(
$"Encoder returned {encoded.Length} bytes for element {i} of {tag.Name}, expected {elementBytes}");
Buffer.BlockCopy(encoded, 0, result, i * elementBytes, elementBytes);
}
return result;
}
private static bool[] ToBoolArray(object? value, int expectedCount, string tagName)
{
if (value is bool[] direct && direct.Length == expectedCount) return direct;
if (value is System.Collections.IList list && list.Count == expectedCount)
{
var arr = new bool[expectedCount];
for (var i = 0; i < expectedCount; i++) arr[i] = Convert.ToBoolean(list[i]);
return arr;
}
if (value is System.Collections.IList wrong)
throw new ModbusWriteRefusedException(StatusBadOutOfRange,
$"Coil-array write to {tagName} expects {expectedCount} elements; got {wrong.Count}");
throw new ModbusWriteRefusedException(StatusBadTypeMismatch,
$"Coil-array write to {tagName} expects a bool[] (or convertible IList) of length {expectedCount}; got {value?.GetType().Name ?? "null"}");
}
private static int ElementByteCount(ModbusTagDefinition tag) => tag.DataType switch
{
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.Bcd16 => 2,
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 or ModbusDataType.Bcd32 => 4,
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 8,
// A String element is StringLength chars at 2 per register, rounded up — the read path's width.
ModbusDataType.String => RegisterCount(tag) * 2,
_ => throw new ModbusWriteRefusedException(StatusBadNotSupported,
$"Array writes are not supported for {tag.DataType} tags ({tag.Name})"),
};
/// <summary>
/// <c>BitInRegister[]</c> write: element i is bit <c>BitIndex</c> of holding register
/// <c>Address + i</c> (the read path's layout). One FC03 of the whole span, flip the addressed bit of
/// each register, one FC16 back — so the other 15 bits of every register are preserved. Serialised
/// per register with the scalar bit-write path (locks taken in ascending address order, so two
/// overlapping array writes cannot deadlock). Not atomic against the PLC's own writes between the
/// read and the write, exactly like the scalar RMW.
/// </summary>
private async Task WriteBitInRegisterArrayAsync(
IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
{
var count = tag.ArrayCount!.Value;
var bits = ToBoolArray(value, count, tag.Name);
var writeCap = _options.MaxRegistersPerWrite == 0 ? (ushort)123 : _options.MaxRegistersPerWrite;
if (count > writeCap || tag.Address + count - 1 > ushort.MaxValue)
throw new ModbusWriteRefusedException(StatusBadOutOfRange,
$"BitInRegister array write of {count} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}");
if (tag.BitIndex > 15)
throw new ModbusWriteRefusedException(StatusBadOutOfRange,
$"BitInRegister bit index {tag.BitIndex} out of range (0-15) for tag {tag.Name}.");
var locks = new List<SemaphoreSlim>(count);
try
{
for (var i = 0; i < count; i++)
{
var l = GetRmwLock((ushort)(tag.Address + i));
await l.WaitAsync(ct).ConfigureAwait(false);
locks.Add(l);
}
var unit = ResolveUnitId(tag);
var current = await ReadRegisterBlockAsync(transport, unit, 0x03, tag.Address, (ushort)count, ct).ConfigureAwait(false);
var payload = new byte[count * 2];
for (var i = 0; i < count; i++)
{
var reg = (ushort)((current[i * 2] << 8) | current[i * 2 + 1]);
reg = bits[i] ? (ushort)(reg | (1 << tag.BitIndex)) : (ushort)(reg & ~(1 << tag.BitIndex));
payload[i * 2] = (byte)(reg >> 8);
payload[i * 2 + 1] = (byte)(reg & 0xFF);
}
// [fc][addr:2][qty:2][byteCount] + data: exactly 6 + byteCount.
var pdu = new byte[6 + payload.Length];
pdu[0] = 0x10;
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
pdu[3] = (byte)(count >> 8); pdu[4] = (byte)(count & 0xFF);
pdu[5] = (byte)payload.Length;
Buffer.BlockCopy(payload, 0, pdu, 6, payload.Length);
await transport.SendAsync(unit, pdu, ct).ConfigureAwait(false);
}
finally
{
for (var i = locks.Count - 1; i >= 0; i--) locks[i].Release();
}
}
/// <summary>
/// Read-modify-write one bit in a holding register. FC03 → bit-swap → FC06. Serialised
/// against other bit writes targeting the same register via <see cref="GetRmwLock"/>.
/// </summary>
private async Task WriteBitInRegisterAsync(
IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
{
var bit = tag.BitIndex;
if (bit > 15)
throw new InvalidOperationException(
$"BitInRegister bit index {bit} out of range (0-15) for tag {tag.Name}.");
var on = Convert.ToBoolean(value);
var rmwLock = GetRmwLock(tag.Address);
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
try
{
// FC03 read 1 holding register at tag.Address.
var readRaw = await ReadRegisterBlockAsync(transport, ResolveUnitId(tag), 0x03, tag.Address, 1, ct).ConfigureAwait(false);
// readRaw = [hi][lo] — ReadRegisterBlockAsync strips the fc+byte-count header and
// validates the payload length, so [0] and [1] are always safe to index here.
var current = (ushort)((readRaw[0] << 8) | readRaw[1]);
var updated = on
? (ushort)(current | (1 << bit))
: (ushort)(current & ~(1 << bit));
// FC06 write single holding register.
var writePdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
(byte)(updated >> 8), (byte)(updated & 0xFF) };
await transport.SendAsync(ResolveUnitId(tag), writePdu, ct).ConfigureAwait(false);
}
finally
{
rmwLock.Release();
}
}
// ---- ISubscribable (polling overlay via shared engine) ----
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
/// <inheritdoc />
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
// Async teardown: the engine drains the in-flight poll without blocking the caller's
// thread — this runs inside a DriverInstanceActor mailbox turn.
await _poll.UnsubscribeAsync(handle).ConfigureAwait(false);
// Drop any partially-collected cycle buffer so a torn-down subscription leaks nothing.
// The teardown above awaits the loop task, so no onChange can re-add after this point.
_cycleBatchItems.TryRemove(handle, out _);
}
// ---- IHostConnectivityProbe ----
/// <inheritdoc />
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
/// <summary>
/// Host identifier surfaced to <c>IHostConnectivityProbe.GetHostStatuses</c> and the Admin UI.
/// Formatted as <c>host:port</c> so multiple Modbus drivers in the same server disambiguate
/// by endpoint without needing the driver-instance-id in the Admin dashboard.
/// </summary>
public string HostName => $"{_options.Host}:{_options.Port}";
private async Task ProbeLoopAsync(int generation, IModbusTransport transport, CancellationToken ct)
{
// The transport is captured at start; disposal tears the loop down via ct.
while (!ct.IsCancellationRequested)
{
var answered = false;
try
{
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(_options.Probe.Timeout);
var pdu = new byte[] { 0x03,
(byte)(_options.Probe.ProbeAddress >> 8),
(byte)(_options.Probe.ProbeAddress & 0xFF), 0x00, 0x01 };
_ = await transport.SendAsync(_options.UnitId, pdu, probeCts.Token).ConfigureAwait(false);
answered = true;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (ModbusException mex) when (mex.ExceptionCode is not (0x0A or 0x0B))
{
// An exception PDU from the device itself (e.g. a locked probe register) proves the host
// is up — it answered. Only a gateway reporting its target unreachable (0A/0B) is Stopped.
answered = true;
}
catch
{
// transport / timeout / gateway-target failure — treated as Stopped below
}
// A probe that finished after teardown cancelled the loop must not raise a stale transition.
if (ct.IsCancellationRequested) return;
if (answered) NoteDeviceAnswered(generation);
TransitionTo(answered ? HostState.Running : HostState.Stopped);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
}
private void TransitionTo(HostState newState)
{
HostState old;
lock (_probeLock)
{
old = _hostState;
if (old == newState) return;
_hostState = newState;
_hostStateChangedUtc = DateTime.UtcNow;
}
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
}
// ---- codec ----
/// <summary>
/// How many 16-bit registers a given tag occupies. Accounts for multi-register logical
/// types (Int32/Float32 = 2 regs, Int64/Float64 = 4 regs) and for strings (rounded up
/// from 2 chars per register).
/// </summary>
/// <param name="tag">Tag definition to measure.</param>
/// <returns>The number of 16-bit registers the tag occupies.</returns>
internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
{
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister or ModbusDataType.Bcd16 => 1,
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 or ModbusDataType.Bcd32 => 2,
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
_ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
};
/// <summary>
/// Re-order the input bytes into the big-endian (ABCD) layout the decoders expect.
/// The four orders refer to how bytes A, B, C, D appear on the wire when reading a
/// 32-bit value from two consecutive registers (extends pairwise for 64-bit / 4 regs):
/// <list type="bullet">
/// <item><b>BigEndian (ABCD)</b>: bytes as-is — Modbus spec default.</item>
/// <item><b>WordSwap (CDAB)</b>: swap word pairs (full register reversal across the value).</item>
/// <item><b>ByteSwap (BADC)</b>: swap bytes within each register.</item>
/// <item><b>FullReverse (DCBA)</b>: full byte reversal — equivalent to little-endian.</item>
/// </list>
/// </summary>
private static byte[] NormalizeWordOrder(ReadOnlySpan<byte> data, ModbusByteOrder order)
{
if (order == ModbusByteOrder.BigEndian) return data.ToArray();
var result = new byte[data.Length];
var registers = data.Length / 2;
switch (order)
{
case ModbusByteOrder.WordSwap:
// Reverse register order; bytes within each register stay big-endian.
for (var word = 0; word < registers; word++)
{
var srcWord = registers - 1 - word;
result[word * 2] = data[srcWord * 2];
result[word * 2 + 1] = data[srcWord * 2 + 1];
}
break;
case ModbusByteOrder.ByteSwap:
// Keep register order, swap two bytes within each register.
for (var word = 0; word < registers; word++)
{
result[word * 2] = data[word * 2 + 1];
result[word * 2 + 1] = data[word * 2];
}
break;
case ModbusByteOrder.FullReverse:
// Full byte-by-byte reversal — equivalent to interpreting the value little-endian.
for (var i = 0; i < data.Length; i++)
result[i] = data[data.Length - 1 - i];
break;
default:
throw new InvalidOperationException($"Unhandled byte order {order}");
}
return result;
}
/// <summary>Decodes a register value according to the tag's data type.</summary>
/// <param name="data">Raw register bytes.</param>
/// <param name="tag">Tag definition specifying the data type.</param>
/// <returns>The decoded value, boxed as the CLR type matching the tag's data type.</returns>
internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusTagDefinition tag)
{
switch (tag.DataType)
{
case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
case ModbusDataType.Bcd16:
{
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
return (int)DecodeBcd(raw, nibbles: 4);
}
case ModbusDataType.Bcd32:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
var raw = BinaryPrimitives.ReadUInt32BigEndian(b);
return (int)DecodeBcd(raw, nibbles: 8);
}
case ModbusDataType.BitInRegister:
{
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
return (raw & (1 << tag.BitIndex)) != 0;
}
case ModbusDataType.Int32:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadInt32BigEndian(b);
}
case ModbusDataType.UInt32:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadUInt32BigEndian(b);
}
case ModbusDataType.Float32:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadSingleBigEndian(b);
}
case ModbusDataType.Int64:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadInt64BigEndian(b);
}
case ModbusDataType.UInt64:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadUInt64BigEndian(b);
}
case ModbusDataType.Float64:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadDoubleBigEndian(b);
}
case ModbusDataType.String:
{
// ASCII, 2 chars per register. HighByteFirst (standard) packs the first char in
// the high byte of each register; LowByteFirst (DL205/DL260) packs the first char
// in the low byte. Respect StringLength (truncate nul-padded regions).
var chars = new char[tag.StringLength];
for (var i = 0; i < tag.StringLength; i++)
{
var regIdx = i / 2;
var highByte = data[regIdx * 2];
var lowByte = data[regIdx * 2 + 1];
byte b;
if (tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst)
b = (i % 2 == 0) ? highByte : lowByte;
else
b = (i % 2 == 0) ? lowByte : highByte;
if (b == 0) return new string(chars, 0, i);
chars[i] = (char)b;
}
return new string(chars);
}
default:
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
}
}
/// <summary>Encodes a value into register bytes according to the tag's data type.</summary>
/// <param name="value">Value to encode.</param>
/// <param name="tag">Tag definition specifying the data type.</param>
/// <returns>The encoded register bytes.</returns>
internal static byte[] EncodeRegister(object? value, ModbusTagDefinition tag)
{
switch (tag.DataType)
{
case ModbusDataType.Int16:
{
var v = Convert.ToInt16(value);
var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b;
}
case ModbusDataType.UInt16:
{
var v = Convert.ToUInt16(value);
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
}
case ModbusDataType.Bcd16:
{
var v = Convert.ToUInt32(value);
if (v > 9999) throw new OverflowException($"BCD16 value {v} exceeds 4 decimal digits");
var raw = (ushort)EncodeBcd(v, nibbles: 4);
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, raw); return b;
}
case ModbusDataType.Bcd32:
{
var v = Convert.ToUInt32(value);
if (v > 99_999_999u) throw new OverflowException($"BCD32 value {v} exceeds 8 decimal digits");
var raw = EncodeBcd(v, nibbles: 8);
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, raw);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.Int32:
{
var v = Convert.ToInt32(value);
var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.UInt32:
{
var v = Convert.ToUInt32(value);
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.Float32:
{
var v = Convert.ToSingle(value);
var b = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.Int64:
{
var v = Convert.ToInt64(value);
var b = new byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.UInt64:
{
var v = Convert.ToUInt64(value);
var b = new byte[8]; BinaryPrimitives.WriteUInt64BigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.Float64:
{
var v = Convert.ToDouble(value);
var b = new byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.String:
{
var s = Convert.ToString(value) ?? string.Empty;
var regs = (tag.StringLength + 1) / 2;
var b = new byte[regs * 2];
for (var i = 0; i < tag.StringLength && i < s.Length; i++)
{
var regIdx = i / 2;
var destIdx = tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst
? (i % 2 == 0 ? regIdx * 2 : regIdx * 2 + 1)
: (i % 2 == 0 ? regIdx * 2 + 1 : regIdx * 2);
b[destIdx] = (byte)s[i];
}
// remaining bytes stay 0 — nul-padded per PLC convention
return b;
}
case ModbusDataType.BitInRegister:
// Reached only if BitInRegister is somehow passed outside the HoldingRegisters
// path. Normal BitInRegister writes dispatch through WriteBitInRegisterAsync via
// the RMW shortcut in WriteOneAsync.
throw new InvalidOperationException(
"BitInRegister writes must go through WriteBitInRegisterAsync (HoldingRegisters region only).");
default:
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
}
}
/// <summary>
/// Map a Modbus logical type to the driver-agnostic <see cref="DriverDataType"/> used
/// by the address-space builder.
/// </summary>
/// <param name="t">Modbus logical data type to map.</param>
/// <returns>The corresponding driver-agnostic data type.</returns>
internal static DriverDataType MapDataType(ModbusDataType t) => t switch
{
ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean,
ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32,
ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32,
ModbusDataType.Int64 => DriverDataType.Int64,
ModbusDataType.UInt64 => DriverDataType.UInt64,
ModbusDataType.Float32 => DriverDataType.Float32,
ModbusDataType.Float64 => DriverDataType.Float64,
ModbusDataType.String => DriverDataType.String,
ModbusDataType.Bcd16 or ModbusDataType.Bcd32 => DriverDataType.Int32,
_ => DriverDataType.Int32,
};
/// <summary>
/// Decode an N-nibble binary-coded-decimal value. Each nibble of <paramref name="raw"/>
/// encodes one decimal digit (most-significant nibble first). Rejects nibbles &gt; 9 —
/// the hardware sometimes produces garbage during transitions and silent non-BCD reads
/// would quietly corrupt the caller's data.
/// </summary>
/// <param name="raw">Raw BCD value.</param>
/// <param name="nibbles">Number of nibbles to decode.</param>
/// <returns>The decoded decimal value.</returns>
internal static uint DecodeBcd(uint raw, int nibbles)
{
uint result = 0;
for (var i = nibbles - 1; i >= 0; i--)
{
var digit = (raw >> (i * 4)) & 0xF;
if (digit > 9)
throw new InvalidDataException(
$"Non-BCD nibble 0x{digit:X} at position {i} of raw=0x{raw:X}");
result = result * 10 + digit;
}
return result;
}
/// <summary>
/// Encode a decimal value as N-nibble BCD. Caller is responsible for range-checking
/// against the nibble capacity (10^nibbles - 1).
/// </summary>
/// <param name="value">Decimal value to encode.</param>
/// <param name="nibbles">Number of nibbles to encode.</param>
/// <returns>The encoded N-nibble BCD value.</returns>
internal static uint EncodeBcd(uint value, int nibbles)
{
uint result = 0;
for (var i = 0; i < nibbles; i++)
{
var digit = value % 10;
result |= digit << (i * 4);
value /= 10;
}
return result;
}
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadNodeIdUnknown = 0x80340000u;
private const uint StatusBadNotWritable = 0x803B0000u;
private const uint StatusBadOutOfRange = 0x803C0000u;
private const uint StatusBadNotSupported = 0x803D0000u;
private const uint StatusBadDeviceFailure = 0x808B0000u;
private const uint StatusBadCommunicationError = 0x80050000u;
private const uint StatusBadDecodingError = 0x80070000u;
private const uint StatusBadTimeout = 0x800A0000u;
private const uint StatusBadTypeMismatch = 0x80740000u;
/// <summary>
/// Map a server-returned Modbus exception code to the most informative OPC UA
/// StatusCode. Keeps the driver's outward-facing status surface aligned with what a
/// Modbus engineer would expect when reading the spec: exception 02 (Illegal Data
/// Address) surfaces as BadOutOfRange so clients can distinguish "tag wrong" from
/// generic BadInternalError, exception 04 (Server Failure) as BadDeviceFailure so
/// operators see a CPU-mode problem rather than a driver bug, etc. Per
/// <c>docs/v2/dl205.md</c>, DL205/DL260 returns only codes 01-04 — no proprietary
/// extensions.
/// </summary>
/// <param name="exceptionCode">Modbus exception code.</param>
/// <returns>The mapped OPC UA StatusCode value.</returns>
internal static uint MapModbusExceptionToStatus(byte exceptionCode) => exceptionCode switch
{
0x01 => StatusBadNotSupported, // Illegal Function — FC not in supported list
0x02 => StatusBadOutOfRange, // Illegal Data Address — register outside mapped range
0x03 => StatusBadOutOfRange, // Illegal Data Value — quantity over per-FC cap
0x04 => StatusBadDeviceFailure, // Server Failure — CPU in PROGRAM mode during protected write
0x05 or 0x06 => StatusBadDeviceFailure, // Acknowledge / Server Busy — long-running op / busy
0x0A or 0x0B => StatusBadCommunicationError, // Gateway path unavailable / target failed to respond
_ => StatusBadInternalError,
};
/// <summary>Releases resources used by the driver.</summary>
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
/// <summary>
/// DisposeAsync must perform the same teardown as ShutdownAsync so
/// callers that use <c>await using</c> (without an explicit <c>ShutdownAsync</c>) do not
/// leak the probe loop, re-probe loop, and poll-engine background tasks. Shares
/// <see cref="TeardownAsync"/> with <see cref="ShutdownAsync"/> to keep them in sync.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
// Dispose supersedes any in-flight (abandoned) initialize up front, even when it then has to
// bypass the gate: that initialize will find its generation moved on and release its own
// transport instead of publishing it after this teardown.
int generation;
DateTime? lastRead;
lock (_stateLock)
{
generation = ++_generation;
lastRead = _health.LastSuccessfulRead;
}
if (await _lifecycleGate.WaitAsync(LifecycleGateTimeout).ConfigureAwait(false))
{
try
{
await TeardownAsync().ConfigureAwait(false);
}
finally
{
_lifecycleGate.Release();
}
}
else
{
_logger.LogWarning(
"Modbus lifecycle gate still held at dispose; tearing the transport down anyway. Driver={DriverInstanceId}",
_driverInstanceId);
await TeardownAsync().ConfigureAwait(false);
}
// Dispose's own final health write. Bumping the generation above drops a still-running shutdown's
// Unknown write, so without this a disposed driver kept reporting whatever it last was (e.g. Healthy).
WriteLifecycleHealth(generation, new DriverHealth(DriverState.Unknown, lastRead, null));
}
/// <summary>
/// Shared teardown helper used by both <see cref="ShutdownAsync"/> and
/// <see cref="DisposeAsync"/>. Cancels both background loops, disposes the poll engine,
/// and disposes the transport. Idempotent — safe to call more than once.
/// </summary>
private async Task TeardownAsync()
{
await StopBackgroundLoopsAsync().ConfigureAwait(false);
// Drain the poll loops BEFORE dropping the tables and caches they read, so no in-flight cycle can
// re-seed a cache after it was cleared.
await _poll.DisposeAsync().ConfigureAwait(false);
// After the engine's loops are drained, no onChange can re-populate the per-cycle buffers.
_cycleBatchItems.Clear();
IModbusTransport? transport;
lock (_stateLock)
{
transport = _transport;
_transport = null;
// Swap, never .Clear() in place: a reader that captured the old table keeps a consistent view.
Volatile.Write(ref _tagsByRawPath, new Dictionary<string, ModbusTagDefinition>(StringComparer.Ordinal));
}
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_lastPublishedByRef.Clear();
lock (_lastWrittenLock) _lastWrittenByRef.Clear();
lock (_autoProhibitedLock) _autoProhibited.Clear();
if (transport is not null) await transport.DisposeAsync().ConfigureAwait(false);
}
}