Compare commits
2 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b5222f5db | ||
|
|
8248b126ce |
74
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs
Normal file
74
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// AutomationDirect DirectLOGIC address-translation helpers. DL205 / DL260 / DL350 CPUs
|
||||
/// address V-memory in OCTAL while the Modbus wire uses DECIMAL PDU addresses — operators
|
||||
/// see "V2000" in the PLC ladder-logic editor but the Modbus client must write PDU 0x0400.
|
||||
/// The formulas differ between user V-memory (simple octal-to-decimal) and system V-memory
|
||||
/// (fixed bank mappings), so the two cases are separate methods rather than one overloaded
|
||||
/// "ToPdu" call.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See <c>docs/v2/dl205.md</c> §V-memory for the full CPU-family matrix + rationale.
|
||||
/// References: D2-USER-M appendix (DL205/D2-260), H2-ECOM-M §6.5 (absolute vs relative
|
||||
/// addressing), AutomationDirect forum guidance on V40400 system-base.
|
||||
/// </remarks>
|
||||
public static class DirectLogicAddress
|
||||
{
|
||||
/// <summary>
|
||||
/// Convert a DirectLOGIC user V-memory address (octal) to a 0-based Modbus PDU address.
|
||||
/// Accepts bare octal (<c>"2000"</c>) or <c>V</c>-prefixed (<c>"V2000"</c>). Range
|
||||
/// depends on CPU model — DL205 D2-260 user memory is V1400-V7377 + V10000-V17777
|
||||
/// octal, DL260 extends to V77777 octal.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Input is null / empty / contains non-octal digits (8,9).</exception>
|
||||
/// <exception cref="OverflowException">Parsed value exceeds ushort.MaxValue (0xFFFF).</exception>
|
||||
public static ushort UserVMemoryToPdu(string vAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vAddress))
|
||||
throw new ArgumentException("V-memory address must not be empty", nameof(vAddress));
|
||||
var s = vAddress.Trim();
|
||||
if (s[0] == 'V' || s[0] == 'v') s = s.Substring(1);
|
||||
if (s.Length == 0)
|
||||
throw new ArgumentException($"V-memory address '{vAddress}' has no digits", nameof(vAddress));
|
||||
|
||||
// Octal conversion. Reject 8/9 digits up-front — int.Parse in the obvious base would
|
||||
// accept them silently because .NET has no built-in base-8 parser.
|
||||
uint result = 0;
|
||||
foreach (var ch in s)
|
||||
{
|
||||
if (ch < '0' || ch > '7')
|
||||
throw new ArgumentException(
|
||||
$"V-memory address '{vAddress}' contains non-octal digit '{ch}' — DirectLOGIC V-addresses are octal (0-7)",
|
||||
nameof(vAddress));
|
||||
result = result * 8 + (uint)(ch - '0');
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException(
|
||||
$"V-memory address '{vAddress}' exceeds the 16-bit Modbus PDU address range");
|
||||
}
|
||||
return (ushort)result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DirectLOGIC system V-memory starts at octal V40400 on DL260 / H2-ECOM100 in factory
|
||||
/// "absolute" addressing mode. Unlike user V-memory, the mapping is NOT a simple
|
||||
/// octal-to-decimal conversion — the CPU relocates the system bank to Modbus PDU 0x2100
|
||||
/// (decimal 8448). This helper returns the CPU-family base plus a user-supplied offset
|
||||
/// within the system bank.
|
||||
/// </summary>
|
||||
public const ushort SystemVMemoryBasePdu = 0x2100;
|
||||
|
||||
/// <param name="offsetWithinSystemBank">
|
||||
/// 0-based register offset within the system bank. Pass 0 for V40400 itself; pass 1 for
|
||||
/// V40401 (octal), and so on. NOT an octal-decoded value — the system bank lives at
|
||||
/// consecutive PDU addresses, so the offset is plain decimal.
|
||||
/// </param>
|
||||
public static ushort SystemVMemoryToPdu(ushort offsetWithinSystemBank)
|
||||
{
|
||||
var pdu = SystemVMemoryBasePdu + offsetWithinSystemBank;
|
||||
if (pdu > ushort.MaxValue)
|
||||
throw new OverflowException(
|
||||
$"System V-memory offset {offsetWithinSystemBank} maps past 0xFFFF");
|
||||
return (ushort)pdu;
|
||||
}
|
||||
}
|
||||
@@ -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 > 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");
|
||||
|
||||
|
||||
@@ -89,6 +89,18 @@ public enum ModbusDataType
|
||||
BitInRegister,
|
||||
/// <summary>ASCII string packed 2 chars per register, <see cref="ModbusTagDefinition.StringLength"/> characters long.</summary>
|
||||
String,
|
||||
/// <summary>
|
||||
/// 16-bit binary-coded decimal. Each nibble encodes one decimal digit (0-9). Register
|
||||
/// value <c>0x1234</c> decodes as decimal <c>1234</c> — NOT binary <c>0x04D2 = 4660</c>.
|
||||
/// DL205/DL260 and several Mitsubishi / Omron families store timers, counters, and
|
||||
/// operator-facing numerics as BCD by default.
|
||||
/// </summary>
|
||||
Bcd16,
|
||||
/// <summary>
|
||||
/// 32-bit (two-register) BCD. Decodes 8 decimal digits. Word ordering follows
|
||||
/// <see cref="ModbusTagDefinition.ByteOrder"/> the same way <see cref="Int32"/> does.
|
||||
/// </summary>
|
||||
Bcd32,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies DL205/DL260 binary-coded-decimal register handling against the
|
||||
/// <c>dl205.json</c> pymodbus profile. HR[1072] = 0x1234 on the profile represents
|
||||
/// decimal 1234 (BCD nibbles). Reading it as <see cref="ModbusDataType.Int16"/> would
|
||||
/// return 0x1234 = 4660; the <see cref="ModbusDataType.Bcd16"/> path decodes 1234.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the DL205/DL260 V-memory octal addressing quirk end-to-end: use
|
||||
/// <see cref="DirectLogicAddress.UserVMemoryToPdu"/> to translate <c>V2000</c> octal into
|
||||
/// the Modbus PDU address actually dispatched, then read the marker the dl205.json profile
|
||||
/// placed at that address. HR[0x0400] = 0x2000 proves the translation was performed
|
||||
/// correctly — a naïve caller treating "V2000" as decimal 2000 would read HR[2000] (which
|
||||
/// the profile leaves at 0) and miss the marker entirely.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205VMemoryQuirkTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_V2000_user_memory_resolves_to_PDU_0x0400_marker()
|
||||
{
|
||||
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 V-memory markers).");
|
||||
}
|
||||
|
||||
var pdu = DirectLogicAddress.UserVMemoryToPdu("V2000");
|
||||
pdu.ShouldBe((ushort)0x0400);
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("DL205_V2000",
|
||||
ModbusRegion.HoldingRegisters, Address: pdu,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-vmem");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_V2000"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe((ushort)0x2000, "dl205.json seeds HR[0x0400] with marker 0x2000 (= V2000 value)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DL205_V40400_system_memory_resolves_to_PDU_0x2100_marker()
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
// V40400 is system memory on DL260 / H2-ECOM100 absolute mode; it does NOT follow the
|
||||
// simple octal-to-decimal formula (40400 octal = 16640 decimal, which would read HR[0x4100]).
|
||||
// The CPU places the system bank at PDU 0x2100 instead. Proving the helper routes there.
|
||||
var pdu = DirectLogicAddress.SystemVMemoryToPdu(0);
|
||||
pdu.ShouldBe((ushort)0x2100);
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("DL205_V40400",
|
||||
ModbusRegion.HoldingRegisters, Address: pdu,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-sysv");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_V40400"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe((ushort)0x4040, "dl205.json seeds HR[0x2100] with marker 0x4040 (= V40400 value)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DirectLogicAddressTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("V0", (ushort)0x0000)]
|
||||
[InlineData("V1", (ushort)0x0001)]
|
||||
[InlineData("V7", (ushort)0x0007)]
|
||||
[InlineData("V10", (ushort)0x0008)]
|
||||
[InlineData("V2000", (ushort)0x0400)] // canonical DL205/DL260 user-memory start
|
||||
[InlineData("V7777", (ushort)0x0FFF)]
|
||||
[InlineData("V10000", (ushort)0x1000)]
|
||||
[InlineData("V17777", (ushort)0x1FFF)]
|
||||
public void UserVMemoryToPdu_converts_octal_V_prefix(string v, ushort expected)
|
||||
=> DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("0", (ushort)0)]
|
||||
[InlineData("2000", (ushort)0x0400)]
|
||||
[InlineData("v2000", (ushort)0x0400)] // lowercase v
|
||||
[InlineData(" V2000 ", (ushort)0x0400)] // surrounding whitespace
|
||||
public void UserVMemoryToPdu_accepts_bare_or_prefixed_or_padded(string v, ushort expected)
|
||||
=> DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("V8")] // 8 is not a valid octal digit
|
||||
[InlineData("V19")]
|
||||
[InlineData("V2009")]
|
||||
public void UserVMemoryToPdu_rejects_non_octal_digits(string v)
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => DirectLogicAddress.UserVMemoryToPdu(v))
|
||||
.Message.ShouldContain("octal");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("V")]
|
||||
public void UserVMemoryToPdu_rejects_empty_input(string? v)
|
||||
=> Should.Throw<ArgumentException>(() => DirectLogicAddress.UserVMemoryToPdu(v!));
|
||||
|
||||
[Fact]
|
||||
public void UserVMemoryToPdu_overflow_rejected()
|
||||
{
|
||||
// 200000 octal = 0x10000 — one past ushort range.
|
||||
Should.Throw<OverflowException>(() => DirectLogicAddress.UserVMemoryToPdu("V200000"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemVMemoryBasePdu_is_0x2100_for_V40400()
|
||||
{
|
||||
// V40400 on DL260 / H2-ECOM100 absolute mode → PDU 0x2100 (decimal 8448), NOT 0x4100
|
||||
// which a naive octal-to-decimal of 40400 octal would give (= 16640).
|
||||
DirectLogicAddress.SystemVMemoryBasePdu.ShouldBe((ushort)0x2100);
|
||||
DirectLogicAddress.SystemVMemoryToPdu(0).ShouldBe((ushort)0x2100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemVMemoryToPdu_offsets_within_bank()
|
||||
{
|
||||
DirectLogicAddress.SystemVMemoryToPdu(1).ShouldBe((ushort)0x2101);
|
||||
DirectLogicAddress.SystemVMemoryToPdu(0x100).ShouldBe((ushort)0x2200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemVMemoryToPdu_rejects_overflow()
|
||||
{
|
||||
// ushort wrap: 0xFFFF - 0x2100 = 0xDEFF; anything above should throw.
|
||||
Should.NotThrow(() => DirectLogicAddress.SystemVMemoryToPdu(0xDEFF));
|
||||
Should.Throw<OverflowException>(() => DirectLogicAddress.SystemVMemoryToPdu(0xDF00));
|
||||
}
|
||||
}
|
||||
@@ -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<InvalidDataException>(() => 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<OverflowException>(() => 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user