Phase 3 PR 46 -- DL205 BCD decoder (binary-coded-decimal numeric encoding). Adds ModbusDataType.Bcd16 and Bcd32 to the driver. Bcd16 is 1 register wide, Bcd32 is 2 registers wide; Bcd32 respects ModbusByteOrder (BigEndian/WordSwap) the same way Int32 does so the CDAB-style families (including DL205/DL260 themselves) can be configured. DecodeRegister uses the new internal DecodeBcd helper: walks each nibble from MSB to LSB, multiplies the running result by 10, adds the nibble as a decimal digit. Explicitly rejects nibbles > 9 with InvalidDataException -- hardware sometimes produces garbage during write-in-progress transitions and silently returning wrong numeric values would quietly corrupt the caller's data. EncodeRegister's new EncodeBcd inverts the operation (mod/div by 10 nibble-by-nibble) with an up-front overflow check against 10^nibbles-1. Why this matters for DL205/DL260: AutomationDirect DirectLOGIC uses BCD as the default numeric encoding for timers, counters, and operator-display numerics (not binary). A plain Int16 read of register 0x1234 returns 4660; the BCD path returns 1234. The two differ enough that silently defaulting to Int16 would give wildly wrong HMI values -- the caller must opt in to Bcd16/Bcd32 per tag. Unit tests: DecodeBcd (theory: 0,1,9,10,1234,9999), DecodeBcd_rejects_nibbles_above_nine, EncodeBcd (theory), Bcd16_decodes_DL205_register_1234_as_decimal_1234 (control: same bytes as Int16 decode to 4660), Bcd16_encode_round_trips_with_decode, Bcd16_encode_rejects_out_of_range_values, Bcd32_decodes_8_digits_big_endian, Bcd32_word_swap_handles_CDAB_layout, Bcd32_encode_round_trips_with_decode, Bcd_RegisterCount_matches_underlying_width. 66/66 Modbus.Tests pass. Integration test: DL205BcdQuirkTests.DL205_BCD16_decodes_HR1072_as_decimal_1234 against dl205.json pymodbus profile (HR[1072]=0x1234). Asserts Bcd16 decode=1234 AND Int16 decode=0x1234 on the same wire bytes to prove the paths are distinct. 3/3 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205.

This commit is contained in:
Joseph Doherty
2026-04-18 21:46:25 -04:00
parent cd19022d19
commit 8248b126ce
4 changed files with 226 additions and 2 deletions

View File

@@ -404,8 +404,8 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
/// </summary>
internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
{
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister => 1,
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2,
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister or ModbusDataType.Bcd16 => 1,
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 or ModbusDataType.Bcd32 => 2,
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
_ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
@@ -435,6 +435,17 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
{
case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
case ModbusDataType.Bcd16:
{
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
return (int)DecodeBcd(raw, nibbles: 4);
}
case ModbusDataType.Bcd32:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
var raw = BinaryPrimitives.ReadUInt32BigEndian(b);
return (int)DecodeBcd(raw, nibbles: 8);
}
case ModbusDataType.BitInRegister:
{
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
@@ -510,6 +521,21 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
var v = Convert.ToUInt16(value);
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
}
case ModbusDataType.Bcd16:
{
var v = Convert.ToUInt32(value);
if (v > 9999) throw new OverflowException($"BCD16 value {v} exceeds 4 decimal digits");
var raw = (ushort)EncodeBcd(v, nibbles: 4);
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, raw); return b;
}
case ModbusDataType.Bcd32:
{
var v = Convert.ToUInt32(value);
if (v > 99_999_999u) throw new OverflowException($"BCD32 value {v} exceeds 8 decimal digits");
var raw = EncodeBcd(v, nibbles: 8);
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, raw);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.Int32:
{
var v = Convert.ToInt32(value);
@@ -579,9 +605,46 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
ModbusDataType.Float32 => DriverDataType.Float32,
ModbusDataType.Float64 => DriverDataType.Float64,
ModbusDataType.String => DriverDataType.String,
ModbusDataType.Bcd16 or ModbusDataType.Bcd32 => DriverDataType.Int32,
_ => DriverDataType.Int32,
};
/// <summary>
/// Decode an N-nibble binary-coded-decimal value. Each nibble of <paramref name="raw"/>
/// encodes one decimal digit (most-significant nibble first). Rejects nibbles &gt; 9 —
/// the hardware sometimes produces garbage during transitions and silent non-BCD reads
/// would quietly corrupt the caller's data.
/// </summary>
internal static uint DecodeBcd(uint raw, int nibbles)
{
uint result = 0;
for (var i = nibbles - 1; i >= 0; i--)
{
var digit = (raw >> (i * 4)) & 0xF;
if (digit > 9)
throw new InvalidDataException(
$"Non-BCD nibble 0x{digit:X} at position {i} of raw=0x{raw:X}");
result = result * 10 + digit;
}
return result;
}
/// <summary>
/// Encode a decimal value as N-nibble BCD. Caller is responsible for range-checking
/// against the nibble capacity (10^nibbles - 1).
/// </summary>
internal static uint EncodeBcd(uint value, int nibbles)
{
uint result = 0;
for (var i = 0; i < nibbles; i++)
{
var digit = value % 10;
result |= digit << (i * 4);
value /= 10;
}
return result;
}
private IModbusTransport RequireTransport() =>
_transport ?? throw new InvalidOperationException("ModbusDriver not initialized");