docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
This commit is contained in:
Joseph Doherty
2026-07-07 12:38:39 -04:00
parent 384dbd7d36
commit 9cad9ed0fc
375 changed files with 1899 additions and 2493 deletions
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// #152 — operator-visible snapshot of one auto-prohibited coalesced range. Returned in
/// Operator-visible snapshot of one auto-prohibited coalesced range. Returned in
/// bulk by <see cref="ModbusDriver.GetAutoProhibitedRanges"/>; consumers (Admin UI,
/// dashboards, log-aggregation pipelines) project the list into whatever shape they need.
/// </summary>
@@ -11,8 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <param name="EndAddress">Inclusive end of the prohibited range. Equals <paramref name="StartAddress"/> when bisection has narrowed to a single register.</param>
/// <param name="LastProbedUtc">Wall-clock time of the most recent failure (record) or re-probe (refresh).</param>
/// <param name="BisectionPending">
/// True when the range still spans &gt; 1 register and the next re-probe will bisect it
/// (per #150). False when the range is single-register or has been pinned permanent.
/// True when the range still spans &gt; 1 register and the next re-probe will bisect it.
/// False when the range is single-register or has been pinned permanent.
/// </param>
public sealed record ModbusAutoProhibition(
byte UnitId,
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
public sealed class ModbusDriver
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
{
// ---- instance fields (Driver.Modbus-011: grouped at top for auditability) ----
// ---- instance fields (grouped at top for auditability) ----
private readonly ModbusDriverOptions _options;
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
@@ -42,10 +42,6 @@ public sealed class ModbusDriver
// Last-published value per tag, keyed by FullReference. Used by ShouldPublish to apply
// the deadband filter. Stored as object so all numeric types share one map; the comparison
// does a typed cast inside.
// Driver.Modbus-001: ShouldPublish runs on the PollGroupEngine onChange callback, which
// executes on one background Task per subscription — so a multi-subscription driver mutates
// this map concurrently from several threads. A plain Dictionary corrupts under concurrent
// writes; ConcurrentDictionary makes every TryGetValue / indexer write thread-safe.
private readonly ConcurrentDictionary<string, object> _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);
// Last-written value per tag for the WriteOnChangeOnly suppression. Invalidated by reads
@@ -57,7 +53,6 @@ public sealed class ModbusDriver
// per-register lock keeps concurrent bit-write callers from stomping on each other.
private readonly ConcurrentDictionary<ushort, SemaphoreSlim> _rmwLocks = new();
// #148 auto-prohibited coalesce ranges + #150 bisection state (see ProhibitionState below).
private readonly Dictionary<(byte Unit, ModbusRegion Region, ushort Start, ushort End), ProhibitionState> _autoProhibited = new();
private readonly object _autoProhibitedLock = new();
@@ -72,14 +67,6 @@ public sealed class ModbusDriver
private CancellationTokenSource? _probeCts;
private CancellationTokenSource? _reprobeCts;
// Driver.Modbus-003: every read / write / probe path writes to _health from a different
// thread, and GetHealth() reads it without coordination. Reference-assignment on .NET is
// atomic for sealed-record refs (so no tearing), but without a happens-before barrier a
// stale snapshot can persist on another core indefinitely. Volatile.Write / Volatile.Read
// give GetHealth() a defined ordering guarantee: any subsequent read sees at least the
// most recent write any thread has published. The field stays a plain reference (you can't
// mark a record-typed field 'volatile' through the C# keyword on every framework version,
// and the Volatile API is the documented portable form).
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>Occurs when a subscribed tag value changes.</summary>
@@ -90,7 +77,7 @@ public sealed class ModbusDriver
// ---- nested types ----
/// <summary>
/// #150 — per-prohibition state. <c>SplitPending</c> drives the re-probe loop's
/// 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.
@@ -129,20 +116,12 @@ public sealed class ModbusDriver
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
{
// #141 deadband filter: when configured on a tag, suppress publishes whose
// numeric distance from the last-published value is below the threshold.
if (!ShouldPublish(tagRef, snapshot)) return;
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
});
}
/// <summary>
/// #142 multi-unit-ID gateway support: per-tag UnitId override drives per-slave host
/// name surfacing through this method. The resilience pipeline keys breakers on the
/// returned host string, so a dead RTU slave behind an Ethernet gateway opens its own
/// breaker without tripping siblings on the same TCP socket.
/// </summary>
/// <param name="fullReference">Tag reference to resolve the host for.</param>
/// <inheritdoc />
public string ResolveHost(string fullReference)
{
if (_tagsByName.TryGetValue(fullReference, out var tag))
@@ -179,14 +158,12 @@ public sealed class ModbusDriver
return true;
}
/// <summary>Gets the unique identifier of this driver instance.</summary>
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <summary>Gets the driver type name.</summary>
/// <inheritdoc />
public string DriverType => "Modbus";
/// <summary>Initializes the driver with the specified configuration JSON.</summary>
/// <param name="driverConfigJson">JSON configuration string.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
@@ -206,7 +183,6 @@ public sealed class ModbusDriver
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
}
// #151 — start the auto-prohibition re-probe loop when the operator opted in.
if (_options.AutoProhibitReprobeInterval is not null)
{
_reprobeCts = new CancellationTokenSource();
@@ -220,17 +196,14 @@ public sealed class ModbusDriver
}
}
/// <summary>Reinitializes the driver with new configuration.</summary>
/// <param name="driverConfigJson">New JSON configuration string.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken);
await InitializeAsync(driverConfigJson, cancellationToken);
}
/// <summary>Shuts down the driver and releases resources.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
var lastRead = ReadHealth().LastSuccessfulRead;
@@ -238,11 +211,11 @@ public sealed class ModbusDriver
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
}
/// <summary>Gets the current driver health status.</summary>
/// <inheritdoc />
public DriverHealth GetHealth() => ReadHealth();
/// <summary>
/// Driver.Modbus-003: barrier-protected read of the multi-thread <c>_health</c> field.
/// 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.
@@ -250,27 +223,20 @@ public sealed class ModbusDriver
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
/// <summary>
/// Driver.Modbus-003: barrier-protected publish of a new <c>_health</c> snapshot.
/// Barrier-protected publish of a new <c>_health</c> snapshot.
/// </summary>
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
/// <summary>Gets the memory footprint of the driver.</summary>
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches to free memory.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
/// <summary>
/// Run-once: <see cref="DiscoverAsync"/> emits the complete node set synchronously from
/// the configured tag table in a single pass — nothing fills in asynchronously after
/// connect, so a single discovery pass is sufficient.
/// </summary>
/// <inheritdoc />
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <summary>Discovers tags and builds the OPC UA address space.</summary>
/// <param name="builder">Address space builder.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -292,9 +258,7 @@ public sealed class ModbusDriver
// ---- IReadable ----
/// <summary>Reads the specified tag references from the Modbus device.</summary>
/// <param name="fullReferences">Tag references to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
@@ -302,9 +266,6 @@ public sealed class ModbusDriver
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
// #143 block-read coalescing: when MaxReadGap is non-zero, route eligible tags through
// the coalescing planner first. Tags it can't coalesce (arrays, coils, prohibited,
// unknown) fall through to the per-tag loop below with results[i] still default.
var coalesced = _options.MaxReadGap > 0
? await ReadCoalescedAsync(transport, fullReferences, results, now, cancellationToken).ConfigureAwait(false)
: new HashSet<int>();
@@ -405,8 +366,6 @@ public sealed class ModbusDriver
/// </summary>
private static object DecodeBitArray(ReadOnlySpan<byte> bitmap, int count, bool isArray)
{
// Driver.Modbus-005: guard against empty bitmap (already validated upstream but defensive
// here so the IndexOutOfRangeException path is explicitly closed at decode time too).
if (bitmap.IsEmpty)
throw new InvalidDataException("Modbus bit response produced an empty bitmap — cannot decode coil value");
if (!isArray) return (bitmap[0] & 0x01) == 1;
@@ -511,7 +470,7 @@ public sealed class ModbusDriver
}
}
/// <summary>Resolve the UnitId for a tag — per-tag override (#142) or driver-level fallback.</summary>
/// <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)
@@ -545,10 +504,6 @@ public sealed class ModbusDriver
};
}
// #152 — structured warning so log-aggregation systems can alert on the event.
// First-time prohibitions get logged; re-fires of the same range stay quiet to avoid
// flooding when a per-tick exception keeps the same range bad. The state visible via
// GetAutoProhibitedRanges shows operators the long-tail picture.
if (isNew)
_logger.LogWarning(
"Modbus coalesced read failed; auto-prohibited range recorded. Driver={DriverInstanceId} Unit={Unit} Region={Region} Start={Start} End={End} Span={Span}",
@@ -556,7 +511,7 @@ public sealed class ModbusDriver
}
/// <summary>
/// #153 — info log when a re-probe clears a prohibition. Operators see recovery
/// 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) =>
@@ -565,12 +520,13 @@ public sealed class ModbusDriver
_driverInstanceId, unit, region, start, end);
/// <summary>
/// #152 — operator-visible snapshot of every range the planner has learned to read
/// 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 (#148), narrowed by bisection (#150), cleared by the
/// re-probe loop (#151) when ranges become healthy again.
/// 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)
@@ -588,7 +544,7 @@ public sealed class ModbusDriver
}
/// <summary>
/// #151 — periodic re-probe loop, augmented in #150 with bisection-style narrowing.
/// 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
@@ -605,10 +561,6 @@ public sealed class ModbusDriver
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch (ObjectDisposedException) when (ct.IsCancellationRequested)
{
// Driver.Modbus-006: ShutdownAsync disposes the transport while we may be
// mid-pass. An ObjectDisposedException from the disposed transport is the
// expected shutdown race — swallow it here so the fire-and-forget task
// exits cleanly rather than faulting with the wrong failure mode.
return;
}
}
@@ -621,6 +573,7 @@ public sealed class ModbusDriver
/// 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");
@@ -661,7 +614,7 @@ public sealed class ModbusDriver
}
/// <summary>
/// #150 — bisect a multi-register prohibition. Removes the parent entry and re-adds
/// 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>
@@ -699,8 +652,6 @@ public sealed class ModbusDriver
// and the next normal scan can re-coalesce across the whole original range.
}
// #153 — log per-half outcome OUTSIDE the lock (logger calls can be expensive).
// Both halves clear → emit a single combined "fully cleared" line.
if (!leftFailed && !rightFailed)
LogProhibitionCleared(key.Unit, key.Region, key.Start, key.End);
else
@@ -726,7 +677,7 @@ public sealed class ModbusDriver
}
/// <summary>
/// #143 block-read coalescing planner. Groups eligible tags by (UnitId, Region), sorts
/// Block-read coalescing planner. Groups eligible tags by (UnitId, Region), sorts
/// by start address, and merges adjacent / near-adjacent (gap ≤ MaxReadGap) into single
/// FC03/FC04 reads. Per-block: emit one Modbus PDU, slice the response back into per-tag
/// values, populate <paramref name="results"/> and the WriteOnChangeOnly cache. Returns
@@ -777,8 +728,6 @@ public sealed class ModbusDriver
var gap = tagStart - last.End - 1;
var newEnd = Math.Max(tagEnd, last.End);
var newSpan = newEnd - last.Start + 1;
// #148 — skip merges that would re-attempt a known-bad range. The
// per-tag fallback will read each member individually instead.
var crossesProhibition = RangeIsAutoProhibited(group.Key.Unit, group.Key.Region, last.Start, (ushort)newEnd);
if (gap <= _options.MaxReadGap && newSpan <= cap && !crossesProhibition)
{
@@ -791,11 +740,11 @@ public sealed class ModbusDriver
}
// Issue one PDU per block. On a Modbus-level exception (illegal data address /
// protected register), record the range as auto-prohibited (#148), leave the
// 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 transport-level failure (timeout / socket
// drop) mark members Bad and short-circuit the per-tag fallback (hitting the
// dead socket again won't help). #150 bisection narrows the prohibition over
// dead socket again won't help). Bisection narrows the prohibition over
// subsequent re-probe ticks.
foreach (var block in blocks)
{
@@ -823,13 +772,6 @@ public sealed class ModbusDriver
}
catch (ModbusException mex)
{
// #148 — record the failed range so the planner stops re-coalescing across
// it on subsequent scans. The members are intentionally NOT added to the
// handled-set: ReadAsync's per-tag fallback runs them individually in the
// same scan, so healthy tags around the protected hole keep working without
// operator intervention. Members that ARE the protected register will fail
// again at single-tag granularity and surface the per-tag exception code
// naturally — the block-level mex isn't propagated.
RecordAutoProhibition(group.Key.Unit, group.Key.Region, block.Start, block.End);
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, mex.Message));
}
@@ -871,8 +813,6 @@ public sealed class ModbusDriver
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.
// Driver.Modbus-005: guard resp.Length >= 2 (fc + byte-count) and that the payload is
// at least as long as the declared byte-count, matching the quantity we requested.
if (resp.Length < 2)
throw new InvalidDataException(
$"Modbus register response too short: expected at least 2 bytes (fc+bytecount), got {resp.Length}");
@@ -894,7 +834,6 @@ public sealed class ModbusDriver
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);
// Driver.Modbus-005: validate the response is structurally sound before indexing.
if (resp.Length < 2)
throw new InvalidDataException(
$"Modbus bit response too short: expected at least 2 bytes (fc+bytecount), got {resp.Length}");
@@ -954,9 +893,7 @@ public sealed class ModbusDriver
// ---- IWritable ----
/// <summary>Writes values to the specified tag references on the Modbus device.</summary>
/// <param name="writes">Write requests to execute.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
@@ -975,8 +912,6 @@ public sealed class ModbusDriver
results[i] = new WriteResult(StatusBadNotWritable);
continue;
}
// #141 WriteOnChangeOnly suppression: skip the wire round-trip when the same value
// was already successfully written and no read since has invalidated the cache.
if (_options.WriteOnChangeOnly && IsRedundantWrite(w.FullReference, w.Value))
{
results[i] = new WriteResult(0u);
@@ -996,10 +931,6 @@ public sealed class ModbusDriver
}
catch (InvalidDataException)
{
// Driver.Modbus-014: malformed/truncated PDU during a write (e.g. the FC03 RMW
// read returning a short response). This is a communication-layer error — surface
// as BadCommunicationError to match the ReadAsync path, not BadInternalError which
// implies a driver code defect.
results[i] = new WriteResult(StatusBadCommunicationError);
}
catch (Exception)
@@ -1180,12 +1111,6 @@ public sealed class ModbusDriver
try
{
// FC03 read 1 holding register at tag.Address.
// Driver.Modbus-013: use ReadRegisterBlockAsync so the response is validated before
// indexing (mirrors the fix applied to the normal read path in Driver.Modbus-005).
// Direct transport.SendAsync previously skipped the length checks, letting a truncated
// PDU throw IndexOutOfRangeException from readResp[2]/[3] — now surfaces as a clean
// InvalidDataException, which WriteAsync's communication-error catch arm maps to
// BadCommunicationError (see Driver.Modbus-014).
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.
@@ -1208,17 +1133,12 @@ public sealed class ModbusDriver
// ---- ISubscribable (polling overlay via shared engine) ----
/// <summary>Subscribes to value changes on the specified tag references.</summary>
/// <param name="fullReferences">Tag references to subscribe to.</param>
/// <param name="publishingInterval">Interval for publishing changes.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
/// <summary>Unsubscribes from value changes using the specified handle.</summary>
/// <param name="handle">Subscription handle.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
@@ -1227,7 +1147,7 @@ public sealed class ModbusDriver
// ---- IHostConnectivityProbe ----
/// <summary>Gets the current connectivity status for all hosts.</summary>
/// <inheritdoc />
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
@@ -1294,6 +1214,7 @@ public sealed class ModbusDriver
/// 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,
@@ -1354,6 +1275,7 @@ public sealed class ModbusDriver
/// <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)
@@ -1435,6 +1357,7 @@ public sealed class ModbusDriver
/// <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)
@@ -1531,6 +1454,8 @@ public sealed class ModbusDriver
/// 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,
@@ -1553,6 +1478,7 @@ public sealed class ModbusDriver
/// </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;
@@ -1573,6 +1499,7 @@ public sealed class ModbusDriver
/// </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;
@@ -1607,6 +1534,7 @@ public sealed class ModbusDriver
/// 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
@@ -1622,11 +1550,12 @@ public sealed class ModbusDriver
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
/// <summary>
/// Driver.Modbus-004: DisposeAsync must perform the same teardown as ShutdownAsync so
/// 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()
{
await TeardownAsync().ConfigureAwait(false);
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Static factory registration helper for <see cref="ModbusDriver"/>. Server's Program.cs
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
/// calls <see cref="Register"/> once at startup; the bootstrapper then
/// materialises Modbus DriverInstance rows from the central config DB into live driver
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c> / <c>FocasDriverFactoryExtensions</c>.
/// </summary>
@@ -33,6 +33,7 @@ public static class ModbusDriverFactoryExtensions
/// <summary>Public for the Server-side bootstrapper + test consumers (Admin.Tests, etc.).</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
/// <returns>The constructed <see cref="ModbusDriver"/> instance.</returns>
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
@@ -40,6 +41,7 @@ public static class ModbusDriverFactoryExtensions
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
/// <returns>The constructed <see cref="ModbusDriver"/> instance.</returns>
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
@@ -118,11 +120,6 @@ public static class ModbusDriverFactoryExtensions
var name = t.Name ?? throw new InvalidOperationException(
$"Modbus config for '{driverInstanceId}' has a tag missing Name");
// Driver.Modbus-009: a String tag with StringLength = 0 yields RegisterCount = 0, which
// turns into an FC03/FC04 with quantity 0 — a spec-illegal request the PLC rejects with
// exception 03. Catch the misconfiguration at bind time with a clear diagnostic instead
// of waiting for the cryptic Illegal Data Value to surface at runtime.
// AddressString takes precedence over the structured fields (Region/Address/DataType/
// ByteOrder/BitIndex/StringLength/ArrayCount). Tags can mix forms freely — newer pasted
// rows use the grammar string, legacy rows keep the structured form. Fields not derivable
@@ -178,7 +175,7 @@ public static class ModbusDriverFactoryExtensions
}
/// <summary>
/// Driver.Modbus-009: reject <c>StringLength = 0</c> for <c>String</c>-typed tags. The
/// Rejects <c>StringLength = 0</c> for <c>String</c>-typed tags. The
/// driver computes <c>RegisterCount = (StringLength + 1) / 2</c> which would emit an
/// FC03/FC04 with <c>quantity = 0</c>, a spec-illegal request the PLC rejects with
/// exception 03 (Illegal Data Value). Surface as a clear bind-time error.
@@ -249,7 +246,7 @@ public static class ModbusDriverFactoryExtensions
/// <summary>Gets or sets the probe configuration.</summary>
public ModbusProbeDto? Probe { get; init; }
/// <summary>Gets or sets the keep-alive configuration (connection-layer knob #139).</summary>
/// <summary>Gets or sets the keep-alive configuration (connection-layer knob).</summary>
public ModbusKeepAliveDto? KeepAlive { get; init; }
/// <summary>Gets or sets the idle disconnect timeout in milliseconds.</summary>
public int? IdleDisconnectMs { get; init; }
@@ -63,8 +63,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
_reconnect = reconnect ?? new ModbusReconnectOptions();
}
/// <summary>Connects to the Modbus TCP server with IPv4 preference.</summary>
/// <param name="ct">The cancellation token for the operation.</param>
/// <inheritdoc />
public async Task ConnectAsync(CancellationToken ct)
{
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
@@ -101,7 +100,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
try
{
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
// Driver.Modbus-009: a TimeSpan < 1s previously truncated to 0 via the int cast,
// A TimeSpan < 1s previously truncated to 0 via the int cast,
// which Windows / Linux interpret as "use the default" — silently defeating the
// configured keep-alive timing. Round up to at least 1 second so a sub-second
// configuration still produces a real keep-alive cadence. Negative values are
@@ -116,21 +115,19 @@ public sealed class ModbusTcpTransport : IModbusTransport
}
/// <summary>
/// Driver.Modbus-009: cast a <see cref="TimeSpan"/> to a whole number of seconds with a
/// minimum of 1 — protects callers from the int-cast truncation that turned 500&#160;ms
/// Cast a <see cref="TimeSpan"/> to a whole number of seconds with a
/// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
/// keep-alive timing into "use the default" on most OSes.
/// </summary>
/// <param name="ts">The timespan to clamp to whole seconds.</param>
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
internal static int ClampToWholeSeconds(TimeSpan ts)
{
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
return seconds < 1 ? 1 : seconds;
}
/// <summary>Sends a Modbus PDU and returns the response, with automatic retry on socket failure.</summary>
/// <param name="unitId">The Modbus unit/slave ID.</param>
/// <param name="pdu">The protocol data unit to send.</param>
/// <param name="ct">The cancellation token for the operation.</param>
/// <inheritdoc />
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
@@ -284,6 +281,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
}
/// <summary>Asynchronously disposes the transport and underlying socket resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed) return;