diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
index 13c9cf0..db07130 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
@@ -404,8 +404,8 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
///
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,
};
+ ///
+ /// Decode an N-nibble binary-coded-decimal value. Each nibble of
+ /// encodes one decimal digit (most-significant nibble first). Rejects nibbles > 9 —
+ /// the hardware sometimes produces garbage during transitions and silent non-BCD reads
+ /// would quietly corrupt the caller's data.
+ ///
+ 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;
+ }
+
+ ///
+ /// Encode a decimal value as N-nibble BCD. Caller is responsible for range-checking
+ /// against the nibble capacity (10^nibbles - 1).
+ ///
+ 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");
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
index aeae634..ee4e91e 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
@@ -89,6 +89,18 @@ public enum ModbusDataType
BitInRegister,
/// ASCII string packed 2 chars per register, characters long.
String,
+ ///
+ /// 16-bit binary-coded decimal. Each nibble encodes one decimal digit (0-9). Register
+ /// value 0x1234 decodes as decimal 1234 — NOT binary 0x04D2 = 4660.
+ /// DL205/DL260 and several Mitsubishi / Omron families store timers, counters, and
+ /// operator-facing numerics as BCD by default.
+ ///
+ Bcd16,
+ ///
+ /// 32-bit (two-register) BCD. Decodes 8 decimal digits. Word ordering follows
+ /// the same way does.
+ ///
+ Bcd32,
}
///
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205BcdQuirkTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205BcdQuirkTests.cs
new file mode 100644
index 0000000..9cb0aac
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205BcdQuirkTests.cs
@@ -0,0 +1,56 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
+
+///
+/// Verifies DL205/DL260 binary-coded-decimal register handling against the
+/// dl205.json pymodbus profile. HR[1072] = 0x1234 on the profile represents
+/// decimal 1234 (BCD nibbles). Reading it as would
+/// return 0x1234 = 4660; the path decodes 1234.
+///
+[Collection(ModbusSimulatorCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Device", "DL205")]
+public sealed class DL205BcdQuirkTests(ModbusSimulatorFixture sim)
+{
+ [Fact]
+ public async Task DL205_BCD16_decodes_HR1072_as_decimal_1234()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed HR[1072]).");
+ }
+
+ var options = new ModbusDriverOptions
+ {
+ Host = sim.Host,
+ Port = sim.Port,
+ UnitId = 1,
+ Timeout = TimeSpan.FromSeconds(2),
+ Tags =
+ [
+ new ModbusTagDefinition("DL205_Count_Bcd",
+ ModbusRegion.HoldingRegisters, Address: 1072,
+ DataType: ModbusDataType.Bcd16, Writable: false),
+ new ModbusTagDefinition("DL205_Count_Int16",
+ ModbusRegion.HoldingRegisters, Address: 1072,
+ DataType: ModbusDataType.Int16, Writable: false),
+ ],
+ Probe = new ModbusProbeOptions { Enabled = false },
+ };
+ await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-bcd");
+ await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var results = await driver.ReadAsync(["DL205_Count_Bcd", "DL205_Count_Int16"],
+ TestContext.Current.CancellationToken);
+
+ results[0].StatusCode.ShouldBe(0u);
+ results[0].Value.ShouldBe(1234, "DL205 BCD register 0x1234 represents decimal 1234 per the DirectLOGIC convention");
+
+ results[1].StatusCode.ShouldBe(0u);
+ results[1].Value.ShouldBe((short)0x1234, "same register read as Int16 returns the raw 0x1234 = 4660 value — proves BCD path is distinct");
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDataTypeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDataTypeTests.cs
index 6b07f3a..9b99764 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDataTypeTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDataTypeTests.cs
@@ -219,4 +219,97 @@ public sealed class ModbusDataTypeTests
ModbusDriver.DecodeRegister(wire, hi).ShouldBe("He");
ModbusDriver.DecodeRegister(wire, lo).ShouldBe("eH");
}
+
+ // --- BCD (binary-coded decimal, DL205/DL260 default numeric encoding) ---
+
+ [Theory]
+ [InlineData(0x0000u, 0u)]
+ [InlineData(0x0001u, 1u)]
+ [InlineData(0x0009u, 9u)]
+ [InlineData(0x0010u, 10u)]
+ [InlineData(0x1234u, 1234u)]
+ [InlineData(0x9999u, 9999u)]
+ public void DecodeBcd_16_bit_decodes_expected_decimal(uint raw, uint expected)
+ => ModbusDriver.DecodeBcd(raw, nibbles: 4).ShouldBe(expected);
+
+ [Fact]
+ public void DecodeBcd_rejects_nibbles_above_nine()
+ {
+ Should.Throw(() => ModbusDriver.DecodeBcd(0x00A5u, nibbles: 4))
+ .Message.ShouldContain("Non-BCD nibble");
+ }
+
+ [Theory]
+ [InlineData(0u, 0x0000u)]
+ [InlineData(5u, 0x0005u)]
+ [InlineData(42u, 0x0042u)]
+ [InlineData(1234u, 0x1234u)]
+ [InlineData(9999u, 0x9999u)]
+ public void EncodeBcd_16_bit_encodes_expected_nibbles(uint value, uint expected)
+ => ModbusDriver.EncodeBcd(value, nibbles: 4).ShouldBe(expected);
+
+ [Fact]
+ public void Bcd16_decodes_DL205_register_1234_as_decimal_1234()
+ {
+ // HR[1072] = 0x1234 on the DL205 profile represents decimal 1234. A plain Int16 decode
+ // would return 0x04D2 = 4660 — proof the BCD path is different.
+ var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
+ ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34 }, tag).ShouldBe(1234);
+
+ var int16Tag = tag with { DataType = ModbusDataType.Int16 };
+ ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34 }, int16Tag).ShouldBe((short)0x1234);
+ }
+
+ [Fact]
+ public void Bcd16_encode_round_trips_with_decode()
+ {
+ var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
+ var wire = ModbusDriver.EncodeRegister(4321, tag);
+ wire.ShouldBe(new byte[] { 0x43, 0x21 });
+ ModbusDriver.DecodeRegister(wire, tag).ShouldBe(4321);
+ }
+
+ [Fact]
+ public void Bcd16_encode_rejects_out_of_range_values()
+ {
+ var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
+ Should.Throw(() => ModbusDriver.EncodeRegister(10000, tag))
+ .Message.ShouldContain("4 decimal digits");
+ }
+
+ [Fact]
+ public void Bcd32_decodes_8_digits_big_endian()
+ {
+ // 0x12345678 as BCD = decimal 12_345_678.
+ var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32);
+ ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34, 0x56, 0x78 }, tag).ShouldBe(12_345_678);
+ }
+
+ [Fact]
+ public void Bcd32_word_swap_handles_CDAB_layout()
+ {
+ // PLC stored 12_345_678 with word swap: low-word 0x5678 first, high-word 0x1234 second.
+ // Wire bytes [0x56, 0x78, 0x12, 0x34] + WordSwap → decode to decimal 12_345_678.
+ var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32,
+ ByteOrder: ModbusByteOrder.WordSwap);
+ ModbusDriver.DecodeRegister(new byte[] { 0x56, 0x78, 0x12, 0x34 }, tag).ShouldBe(12_345_678);
+ }
+
+ [Fact]
+ public void Bcd32_encode_round_trips_with_decode()
+ {
+ var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32);
+ var wire = ModbusDriver.EncodeRegister(87_654_321u, tag);
+ wire.ShouldBe(new byte[] { 0x87, 0x65, 0x43, 0x21 });
+ ModbusDriver.DecodeRegister(wire, tag).ShouldBe(87_654_321);
+ }
+
+ [Fact]
+ public void Bcd_RegisterCount_matches_underlying_width()
+ {
+ var b16 = new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
+ var b32 = new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32);
+ ModbusDriver.RegisterCount(b16).ShouldBe((ushort)1);
+ ModbusDriver.RegisterCount(b32).ShouldBe((ushort)2);
+ }
}