fix(driver-modbus): resolve Low code-review findings (Driver.Modbus-003,007,008,009,010,011,012)

- Driver.Modbus-003: route every _health access through ReadHealth /
  WriteHealth helpers backed by Volatile.Read / Volatile.Write so a
  burst of concurrent ReadAsync callers always sees a complete snapshot.
- Driver.Modbus-007: promoted the Int64 / UInt64 → Int32 surfacing
  caveat to a full <remarks> block; rewrote DisableFC23's doc to flag it
  as reserved / no-op.
- Driver.Modbus-008: deleted stale duplicate doc, rewrote the
  prohibition-block summaries to credit the shipped re-probe loop, and
  removed the unused 'status' local in the ModbusException catch arm.
- Driver.Modbus-009: bind-time validation rejects StringLength < 1 for
  String tags; ModbusTcpTransport clamps keep-alive intervals to whole
  seconds (>=1).
- Driver.Modbus-010: documented WriteOnChangeOnly's cache-invalidation
  policy (reads-only) and the write-only-tag caveat.
- Driver.Modbus-011: collected the scattered instance fields into a
  single contiguous block at the top of ModbusDriver.
- Driver.Modbus-012: covered the previously-uncovered Reinitialize
  state-hygiene, malformed/truncated/empty-bitmap response, and
  DisposeAsync teardown paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 08:17:51 -04:00
parent 3c75db7eb6
commit d5322b0f9a
7 changed files with 695 additions and 136 deletions

View File

@@ -21,30 +21,40 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
public sealed class ModbusDriver
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
{
/// <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>
public string ResolveHost(string fullReference)
{
if (_tagsByName.TryGetValue(fullReference, out var tag))
return BuildSlaveHostName(ResolveUnitId(tag));
// Unknown reference — fall back to driver-instance host (single-slave behaviour).
return HostName;
}
// ---- instance fields (Driver.Modbus-011: grouped at top for auditability) ----
/// <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}";
private readonly 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;
private readonly string _driverInstanceId;
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
// 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
// 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();
// #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();
// 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
@@ -52,15 +62,39 @@ public sealed class ModbusDriver
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private CancellationTokenSource? _probeCts;
private readonly ModbusDriverOptions _options;
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
private IModbusTransport? _transport;
private DriverHealth _health = new(DriverState.Unknown, null, null);
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource? _probeCts;
private CancellationTokenSource? _reprobeCts;
private readonly ILogger<ModbusDriver> _logger;
// 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);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
// ---- nested types ----
/// <summary>
/// #150 — 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 ----
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
@@ -87,19 +121,22 @@ 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);
/// <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>
public string ResolveHost(string fullReference)
{
if (_tagsByName.TryGetValue(fullReference, out var tag))
return BuildSlaveHostName(ResolveUnitId(tag));
// Unknown reference — fall back to driver-instance host (single-slave behaviour).
return HostName;
}
// 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();
/// <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}";
private bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
{
@@ -131,13 +168,13 @@ public sealed class ModbusDriver
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
try
{
_transport = _transportFactory(_options);
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
foreach (var t in _options.Tags) _tagsByName[t.Name] = t;
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
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
@@ -157,7 +194,7 @@ public sealed class ModbusDriver
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
WriteHealth(new DriverHealth(DriverState.Faulted, null, ex.Message));
throw;
}
}
@@ -170,12 +207,25 @@ public sealed class ModbusDriver
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
var lastRead = _health.LastSuccessfulRead;
var lastRead = ReadHealth().LastSuccessfulRead;
await TeardownAsync().ConfigureAwait(false);
_health = new DriverHealth(DriverState.Unknown, lastRead, null);
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
}
public DriverHealth GetHealth() => _health;
public DriverHealth GetHealth() => ReadHealth();
/// <summary>
/// Driver.Modbus-003: 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>
/// Driver.Modbus-003: barrier-protected publish of a new <c>_health</c> snapshot.
/// </summary>
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -228,7 +278,7 @@ public sealed class ModbusDriver
{
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, 0u, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
WriteHealth(new DriverHealth(DriverState.Healthy, now, null));
// Invalidate the WriteOnChangeOnly cache when the read returns a different value
// — typically an HMI-side or PLC-internal change. Without this, a setpoint
@@ -246,14 +296,14 @@ public sealed class ModbusDriver
catch (ModbusException mex)
{
results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, mex.Message);
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, mex.Message));
}
catch (Exception ex)
{
// Non-Modbus-layer failure: socket dropped, timeout, malformed response. Surface
// as communication error so callers can distinguish it from tag-level faults.
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, ex.Message));
}
}
return results;
@@ -402,29 +452,6 @@ public sealed class ModbusDriver
/// <summary>Resolve the UnitId for a tag — per-tag override (#142) or driver-level fallback.</summary>
private byte ResolveUnitId(ModbusTagDefinition tag) => tag.UnitId ?? _options.UnitId;
/// <summary>
/// #148 — runtime-discovered ranges where coalesced reads have failed (typically because
/// the PLC has a write-only or protected register mid-block). Subsequent scans skip
/// coalescing across these ranges and let the per-tag fallback handle the members.
/// Cleared by ReinitializeAsync (operator restart) or by an explicit re-probe API
/// (not yet shipped).
/// </summary>
/// <summary>
/// #150 — 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;
}
private readonly Dictionary<(byte Unit, ModbusRegion Region, ushort Start, ushort End), ProhibitionState> _autoProhibited = new();
private readonly object _autoProhibitedLock = new();
private CancellationTokenSource? _reprobeCts;
private bool RangeIsAutoProhibited(byte unit, ModbusRegion region, ushort start, ushort end)
{
lock (_autoProhibitedLock)
@@ -700,9 +727,13 @@ public sealed class ModbusDriver
blocks.Add((tagStart, tagEnd, new List<(int, ModbusTagDefinition)> { (idx, tag) }));
}
// Issue one PDU per block. On block-level failure mark every member Bad — caller's
// per-tag fallback won't re-try since handled-set already includes them; auto-split-
// on-failure is a follow-up.
// Issue one PDU per block. On a Modbus-level exception (illegal data address /
// protected register), record the range as auto-prohibited (#148), 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
// subsequent re-probe ticks.
foreach (var block in blocks)
{
if (block.Members.Count == 1)
@@ -725,26 +756,19 @@ public sealed class ModbusDriver
handled.Add(idx);
InvalidateWriteCacheIfDiverged(fullReferences[idx], value);
}
_health = new DriverHealth(DriverState.Healthy, timestamp, null);
WriteHealth(new DriverHealth(DriverState.Healthy, timestamp, null));
}
catch (ModbusException mex)
{
// #148 — record the failed range so the planner stops re-coalescing across
// it on subsequent scans. Per-tag fallback reads each member individually
// next time, so healthy tags around the protected hole keep working without
// operator intervention.
// 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);
var status = MapModbusExceptionToStatus(mex.ExceptionCode);
foreach (var (idx, _) in block.Members)
{
// Don't mark members handled — leave them for the per-tag fallback in
// the same scan so single-register reads can succeed for any non-
// protected member. (Pre-#148 behaviour was to mark all Bad and skip.)
// Members that ARE the protected register will fail again at single-tag
// granularity and surface the per-tag exception code naturally.
}
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, mex.Message);
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, mex.Message));
}
catch (Exception ex)
{
@@ -758,7 +782,7 @@ public sealed class ModbusDriver
results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, timestamp);
handled.Add(idx);
}
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, ex.Message));
}
}
}
@@ -926,13 +950,11 @@ public sealed class ModbusDriver
}
}
// 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 —
// 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 readonly System.Collections.Concurrent.ConcurrentDictionary<ushort, SemaphoreSlim> _rmwLocks = new();
// 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));
@@ -1410,12 +1432,34 @@ public sealed class ModbusDriver
}
}
/// <summary>
/// Map a Modbus logical type to the driver-agnostic <see cref="DriverDataType"/> used
/// by the address-space builder.
/// </summary>
/// <remarks>
/// <para>
/// <b>Driver.Modbus-007 — Int64 / UInt64 surfacing limitation:</b>
/// <see cref="DriverDataType"/> does not yet include an Int64 enum member, so 64-bit
/// Modbus tags currently surface as <see cref="DriverDataType.Int32"/> on the OPC UA
/// address space. The wire codec (<c>DecodeRegister</c> / <c>EncodeRegister</c>) is
/// correct — values round-trip as 64-bit <c>long</c> / <c>ulong</c> through
/// <c>ReadAsync</c> / <c>WriteAsync</c>. Only the variable node's <c>DataType</c>
/// attribute is misreported. Clients that consume the type advertisement will see a
/// type/value mismatch for values outside the 32-bit signed range. Operators
/// configuring <c>I_64</c> / <c>UI_64</c> tags should be aware of this until the
/// tracked <c>DriverDataType.Int64</c> follow-up ships.
/// </para>
/// </remarks>
private 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 or ModbusDataType.UInt64 => DriverDataType.Int32, // widening to Int32 loses precision; PR 25 adds Int64 to DriverDataType
// Driver.Modbus-007: Int64 / UInt64 currently surface as Int32 because DriverDataType
// has no Int64 member yet. The wire codec preserves the 64-bit value; only the OPC UA
// node's declared DataType is widened. Tracked for a follow-up that adds the enum
// member + node-type advertisement.
ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32,
ModbusDataType.Float32 => DriverDataType.Float32,
ModbusDataType.Float64 => DriverDataType.Float64,
ModbusDataType.String => DriverDataType.String,

View File

@@ -111,16 +111,22 @@ 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
// from the grammar (Writable, WriteIdempotent, StringByteOrder) always come from the DTO.
ModbusTagDefinition tag;
if (!string.IsNullOrWhiteSpace(t.AddressString))
{
if (!ModbusAddressParser.TryParse(t.AddressString, family, melsecSubFamily, out var parsed, out var parseError))
throw new InvalidOperationException(
$"Modbus tag '{name}' in '{driverInstanceId}' has invalid AddressString '{t.AddressString}': {parseError}");
return new ModbusTagDefinition(
tag = new ModbusTagDefinition(
Name: name,
Region: parsed!.Region,
Address: parsed.Offset,
@@ -137,26 +143,45 @@ public static class ModbusDriverFactoryExtensions
Deadband: t.Deadband,
UnitId: t.UnitId);
}
else
{
tag = new ModbusTagDefinition(
Name: name,
Region: ParseEnum<ModbusRegion>(t.Region, t.Name, driverInstanceId, "Region"),
Address: t.Address ?? throw new InvalidOperationException(
$"Modbus tag '{t.Name}' in '{driverInstanceId}' missing Address"),
DataType: ParseEnum<ModbusDataType>(t.DataType, t.Name, driverInstanceId, "DataType"),
Writable: t.Writable ?? true,
ByteOrder: t.ByteOrder is null
? ModbusByteOrder.BigEndian
: ParseEnum<ModbusByteOrder>(t.ByteOrder, t.Name, driverInstanceId, "ByteOrder"),
BitIndex: t.BitIndex ?? 0,
StringLength: t.StringLength ?? 0,
StringByteOrder: t.StringByteOrder is null
? ModbusStringByteOrder.HighByteFirst
: ParseEnum<ModbusStringByteOrder>(t.StringByteOrder, t.Name, driverInstanceId, "StringByteOrder"),
WriteIdempotent: t.WriteIdempotent ?? false,
ArrayCount: t.ArrayCount,
Deadband: t.Deadband,
UnitId: t.UnitId);
}
return new ModbusTagDefinition(
Name: name,
Region: ParseEnum<ModbusRegion>(t.Region, t.Name, driverInstanceId, "Region"),
Address: t.Address ?? throw new InvalidOperationException(
$"Modbus tag '{t.Name}' in '{driverInstanceId}' missing Address"),
DataType: ParseEnum<ModbusDataType>(t.DataType, t.Name, driverInstanceId, "DataType"),
Writable: t.Writable ?? true,
ByteOrder: t.ByteOrder is null
? ModbusByteOrder.BigEndian
: ParseEnum<ModbusByteOrder>(t.ByteOrder, t.Name, driverInstanceId, "ByteOrder"),
BitIndex: t.BitIndex ?? 0,
StringLength: t.StringLength ?? 0,
StringByteOrder: t.StringByteOrder is null
? ModbusStringByteOrder.HighByteFirst
: ParseEnum<ModbusStringByteOrder>(t.StringByteOrder, t.Name, driverInstanceId, "StringByteOrder"),
WriteIdempotent: t.WriteIdempotent ?? false,
ArrayCount: t.ArrayCount,
Deadband: t.Deadband,
UnitId: t.UnitId);
ValidateStringLength(tag, driverInstanceId);
return tag;
}
/// <summary>
/// Driver.Modbus-009: reject <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.
/// </summary>
private static void ValidateStringLength(ModbusTagDefinition tag, string driverInstanceId)
{
if (tag.DataType == ModbusDataType.String && tag.StringLength < 1)
throw new InvalidOperationException(
$"Modbus tag '{tag.Name}' in '{driverInstanceId}' has DataType=String but StringLength={tag.StringLength}. " +
$"String tags must declare StringLength >= 1 (the number of ASCII characters, packed 2 per register).");
}
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum

View File

@@ -72,10 +72,11 @@ public sealed class ModbusDriverOptions
public bool UseFC16ForSingleRegisterWrites { get; init; } = false;
/// <summary>
/// Reserved kill-switch for FC23 (Read/Write Multiple Registers). The driver does not
/// currently emit FC23, so this option is a no-op today but exists so future block-read
/// coalescing work that opts into FC23 can be disabled per-deployment without a code
/// change. Default <c>false</c> (FC23 not used either way today).
/// <b>Reserved / no-op</b> kill-switch for FC23 (Read/Write Multiple Registers). The
/// driver does not currently emit FC23 — toggling this option has no observable effect
/// today. The slot exists so a future block-read-coalescing enhancement that opts into
/// FC23 can be disabled per-deployment without a code change. Track Driver.Modbus-007
/// for the wiring follow-up. Default <c>false</c>.
/// </summary>
public bool DisableFC23 { get; init; } = false;
@@ -122,6 +123,18 @@ public sealed class ModbusDriverOptions
/// masked. Default <c>false</c> preserves the historical "every write goes to the wire"
/// behaviour. Per-tag deadband lives on <c>ModbusTagDefinition.Deadband</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Driver.Modbus-010 — write-only-tag caveat:</b> the suppression cache is only
/// invalidated by a <i>read</i> that returns a divergent value. A tag that is never
/// subscribed or polled (write-only setpoints, command registers) never sees its
/// cache entry refreshed — so a value the operator believes was re-asserted is
/// silently suppressed forever after the first write. There is no time- or
/// count-based expiry. If you set <see cref="WriteOnChangeOnly"/> = <c>true</c>,
/// either subscribe / poll every tag that needs deterministic re-write, or leave
/// this option <c>false</c> for the affected driver instance.
/// </para>
/// </remarks>
public bool WriteOnChangeOnly { get; init; } = false;
/// <summary>

View File

@@ -91,13 +91,31 @@ public sealed class ModbusTcpTransport : IModbusTransport
try
{
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, (int)opts.Time.TotalSeconds);
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, (int)opts.Interval.TotalSeconds);
// Driver.Modbus-009: 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
// also clamped to 1 to avoid surfacing as OS errors.
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
ClampToWholeSeconds(opts.Time));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
ClampToWholeSeconds(opts.Interval));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
}
catch { /* best-effort; older OSes may not expose the granular knobs */ }
}
/// <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
/// keep-alive timing into "use the default" on most OSes.
/// </summary>
internal static int ClampToWholeSeconds(TimeSpan ts)
{
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
return seconds < 1 ? 1 : seconds;
}
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));