Compare commits
3 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd19022d19 | ||
| 5ee9acb255 | |||
| faeab34541 |
@@ -472,13 +472,21 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
}
|
||||
case ModbusDataType.String:
|
||||
{
|
||||
// ASCII, 2 chars per register, packed high byte = first char.
|
||||
// Respect the caller's StringLength (truncate nul-padded regions).
|
||||
// ASCII, 2 chars per register. HighByteFirst (standard) packs the first char in
|
||||
// the high byte of each register; LowByteFirst (DL205/DL260) packs the first char
|
||||
// in the low byte. Respect StringLength (truncate nul-padded regions).
|
||||
var chars = new char[tag.StringLength];
|
||||
for (var i = 0; i < tag.StringLength; i++)
|
||||
{
|
||||
var b = data[i];
|
||||
if (b == 0) { return new string(chars, 0, i); }
|
||||
var regIdx = i / 2;
|
||||
var highByte = data[regIdx * 2];
|
||||
var lowByte = data[regIdx * 2 + 1];
|
||||
byte b;
|
||||
if (tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst)
|
||||
b = (i % 2 == 0) ? highByte : lowByte;
|
||||
else
|
||||
b = (i % 2 == 0) ? lowByte : highByte;
|
||||
if (b == 0) return new string(chars, 0, i);
|
||||
chars[i] = (char)b;
|
||||
}
|
||||
return new string(chars);
|
||||
@@ -543,7 +551,14 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
var s = Convert.ToString(value) ?? string.Empty;
|
||||
var regs = (tag.StringLength + 1) / 2;
|
||||
var b = new byte[regs * 2];
|
||||
for (var i = 0; i < tag.StringLength && i < s.Length; i++) b[i] = (byte)s[i];
|
||||
for (var i = 0; i < tag.StringLength && i < s.Length; i++)
|
||||
{
|
||||
var regIdx = i / 2;
|
||||
var destIdx = tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst
|
||||
? (i % 2 == 0 ? regIdx * 2 : regIdx * 2 + 1)
|
||||
: (i % 2 == 0 ? regIdx * 2 + 1 : regIdx * 2);
|
||||
b[destIdx] = (byte)s[i];
|
||||
}
|
||||
// remaining bytes stay 0 — nul-padded per PLC convention
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ public sealed class ModbusProbeOptions
|
||||
/// <param name="ByteOrder">Word ordering for multi-register types. Ignored for Bool / Int16 / UInt16 / BitInRegister / String.</param>
|
||||
/// <param name="BitIndex">For <c>DataType = BitInRegister</c>: which bit of the holding register (0-15, LSB-first).</param>
|
||||
/// <param name="StringLength">For <c>DataType = String</c>: number of ASCII characters (2 per register, rounded up).</param>
|
||||
/// <param name="StringByteOrder">
|
||||
/// Per-register byte order for <c>DataType = String</c>. Standard Modbus packs the first
|
||||
/// character in the high byte (<see cref="ModbusStringByteOrder.HighByteFirst"/>).
|
||||
/// AutomationDirect DirectLOGIC (DL205/DL260) and a few legacy families pack the first
|
||||
/// character in the low byte instead — see <c>docs/v2/dl205.md</c> §strings.
|
||||
/// </param>
|
||||
public sealed record ModbusTagDefinition(
|
||||
string Name,
|
||||
ModbusRegion Region,
|
||||
@@ -63,7 +69,8 @@ public sealed record ModbusTagDefinition(
|
||||
bool Writable = true,
|
||||
ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian,
|
||||
byte BitIndex = 0,
|
||||
ushort StringLength = 0);
|
||||
ushort StringLength = 0,
|
||||
ModbusStringByteOrder StringByteOrder = ModbusStringByteOrder.HighByteFirst);
|
||||
|
||||
public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters }
|
||||
|
||||
@@ -95,3 +102,17 @@ public enum ModbusByteOrder
|
||||
BigEndian,
|
||||
WordSwap,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-register byte order for ASCII strings packed 2 chars per register. Standard Modbus
|
||||
/// convention is <see cref="HighByteFirst"/> — the first character of each pair occupies
|
||||
/// the high byte of the register. AutomationDirect DirectLOGIC (DL205, DL260, DL350) and a
|
||||
/// handful of legacy controllers pack <see cref="LowByteFirst"/>, which inverts that within
|
||||
/// each register. Word ordering across multiple registers is always ascending address for
|
||||
/// strings — only the byte order inside each register flips.
|
||||
/// </summary>
|
||||
public enum ModbusStringByteOrder
|
||||
{
|
||||
HighByteFirst,
|
||||
LowByteFirst,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the DL205/DL260 low-byte-first ASCII string packing quirk against the
|
||||
/// <c>dl205.json</c> pymodbus profile. Standard Modbus packs the first char of each pair
|
||||
/// in the high byte of the register; DirectLOGIC packs it in the low byte instead. Without
|
||||
/// <see cref="ModbusStringByteOrder.LowByteFirst"/> the driver decodes "eHllo" garbage
|
||||
/// even though the bytes on the wire are identical.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Requires the dl205 profile (<c>Pymodbus\serve.ps1 -Profile dl205</c>). The standard
|
||||
/// profile does not seed HR[1040..1042] with string bytes, so running this against the
|
||||
/// standard profile returns <c>"\0\0\0\0\0"</c> and the test fails. Skip when the env
|
||||
/// var <c>MODBUS_SIM_PROFILE</c> is not set to <c>dl205</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205StringQuirkTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_string_low_byte_first_decodes_Hello_from_HR1040()
|
||||
{
|
||||
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[1040..1042]).");
|
||||
}
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(
|
||||
Name: "DL205_Hello_Low",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: 1040,
|
||||
DataType: ModbusDataType.String,
|
||||
Writable: false,
|
||||
StringLength: 5,
|
||||
StringByteOrder: ModbusStringByteOrder.LowByteFirst),
|
||||
// Control: same address, HighByteFirst, to prove the driver would have decoded
|
||||
// garbage without the quirk flag.
|
||||
new ModbusTagDefinition(
|
||||
Name: "DL205_Hello_High",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: 1040,
|
||||
DataType: ModbusDataType.String,
|
||||
Writable: false,
|
||||
StringLength: 5,
|
||||
StringByteOrder: ModbusStringByteOrder.HighByteFirst),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-string");
|
||||
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_Hello_Low", "DL205_Hello_High"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe("Hello", "DL205 low-byte-first ordering must produce 'Hello' from HR[1040..1042]");
|
||||
|
||||
// The high-byte-first read of the same wire bytes should differ — not asserting the
|
||||
// exact garbage string (that would couple the test to the ASCII byte math) but the two
|
||||
// decodes MUST disagree, otherwise the quirk flag is a no-op.
|
||||
results[1].StatusCode.ShouldBe(0u);
|
||||
results[1].Value.ShouldNotBe("Hello");
|
||||
}
|
||||
}
|
||||
@@ -172,4 +172,51 @@ public sealed class ModbusDataTypeTests
|
||||
wire[1].ShouldBe((byte)'i');
|
||||
for (var i = 2; i < 8; i++) wire[i].ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
// --- DL205 low-byte-first strings (AutomationDirect DirectLOGIC quirk) ---
|
||||
|
||||
[Fact]
|
||||
public void String_LowByteFirst_decodes_DL205_packed_Hello()
|
||||
{
|
||||
// HR[1040] = 0x6548 (wire BE bytes [0x65, 0x48]) decodes first char from low byte = 'H',
|
||||
// second from high byte = 'e'. HR[1041] = 0x6C6C → 'l','l'. HR[1042] = 0x006F → 'o', nul.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 5, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
|
||||
var wire = new byte[] { 0x65, 0x48, 0x6C, 0x6C, 0x00, 0x6F };
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_LowByteFirst_decode_truncates_at_first_nul()
|
||||
{
|
||||
// Low-byte-first with only 2 real chars in register 0 (lo='H', hi='i') and the rest nul.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 6, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
|
||||
var wire = new byte[] { 0x69, 0x48, 0x00, 0x00, 0x00, 0x00 };
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hi");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_LowByteFirst_encode_round_trips_with_decode()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 5, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
|
||||
var wire = ModbusDriver.EncodeRegister("Hello", tag);
|
||||
// Expect exactly the DL205-documented byte sequence.
|
||||
wire.ShouldBe(new byte[] { 0x65, 0x48, 0x6C, 0x6C, 0x00, 0x6F });
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_HighByteFirst_and_LowByteFirst_differ_on_same_wire()
|
||||
{
|
||||
// Same wire buffer, different byte order → first char switches 'H' vs 'e'.
|
||||
var wire = new byte[] { 0x48, 0x65 };
|
||||
var hi = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 2, StringByteOrder: ModbusStringByteOrder.HighByteFirst);
|
||||
var lo = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 2, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
|
||||
ModbusDriver.DecodeRegister(wire, hi).ShouldBe("He");
|
||||
ModbusDriver.DecodeRegister(wire, lo).ShouldBe("eH");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user