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
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:
@@ -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 > 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 > 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 > 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 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;
|
||||
|
||||
Reference in New Issue
Block a user