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
@@ -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