Compare commits
8 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3f2f95344 | ||
|
|
463c5a4320 | ||
|
|
2b5222f5db | ||
|
|
8248b126ce | ||
|
|
cd19022d19 | ||
| 5ee9acb255 | |||
|
|
02fccbc762 | ||
| faeab34541 |
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;
|
||||
}
|
||||
}
|
||||
@@ -171,11 +171,14 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
{
|
||||
var quantity = RegisterCount(tag);
|
||||
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
||||
var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
// resp = [fc][byte-count][data...]
|
||||
var data = new ReadOnlySpan<byte>(resp, 2, resp[1]);
|
||||
// Auto-chunk when the tag's register span exceeds the caller-configured cap.
|
||||
// Affects long strings (FC03/04 > 125 regs is spec-forbidden; DL205 caps at 128,
|
||||
// Mitsubishi Q caps at 64). Non-string tags max out at 4 regs so the cap never
|
||||
// triggers for numerics.
|
||||
var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
|
||||
var data = quantity <= cap
|
||||
? await ReadRegisterBlockAsync(transport, fc, tag.Address, quantity, ct).ConfigureAwait(false)
|
||||
: await ReadRegisterBlockChunkedAsync(transport, fc, tag.Address, quantity, cap, ct).ConfigureAwait(false);
|
||||
return DecodeRegister(data, tag);
|
||||
}
|
||||
default:
|
||||
@@ -183,6 +186,33 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadRegisterBlockAsync(
|
||||
IModbusTransport transport, byte fc, ushort address, ushort quantity, CancellationToken ct)
|
||||
{
|
||||
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
|
||||
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
// resp = [fc][byte-count][data...]
|
||||
var data = new byte[resp[1]];
|
||||
Buffer.BlockCopy(resp, 2, data, 0, resp[1]);
|
||||
return data;
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadRegisterBlockChunkedAsync(
|
||||
IModbusTransport transport, byte fc, ushort address, ushort totalRegs, ushort cap, CancellationToken ct)
|
||||
{
|
||||
var assembled = new byte[totalRegs * 2];
|
||||
ushort done = 0;
|
||||
while (done < totalRegs)
|
||||
{
|
||||
var chunk = (ushort)Math.Min(cap, totalRegs - done);
|
||||
var chunkBytes = await ReadRegisterBlockAsync(transport, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
|
||||
Buffer.BlockCopy(chunkBytes, 0, assembled, done * 2, chunkBytes.Length);
|
||||
done += chunk;
|
||||
}
|
||||
return assembled;
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
@@ -239,8 +269,13 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
}
|
||||
else
|
||||
{
|
||||
// FC 16 (Write Multiple Registers) for 32-bit types
|
||||
// FC 16 (Write Multiple Registers) for 32-bit types.
|
||||
var qty = (ushort)(bytes.Length / 2);
|
||||
var writeCap = _options.MaxRegistersPerWrite == 0 ? (ushort)123 : _options.MaxRegistersPerWrite;
|
||||
if (qty > writeCap)
|
||||
throw new InvalidOperationException(
|
||||
$"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
|
||||
$"Split the tag (e.g. shorter StringLength) — partial FC16 chunks would lose atomicity.");
|
||||
var pdu = new byte[6 + 1 + bytes.Length];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
||||
@@ -404,8 +439,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 +470,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);
|
||||
@@ -472,13 +518,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);
|
||||
@@ -502,6 +556,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);
|
||||
@@ -543,7 +612,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;
|
||||
}
|
||||
@@ -564,9 +640,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");
|
||||
|
||||
|
||||
@@ -25,6 +25,26 @@ public sealed class ModbusDriverOptions
|
||||
/// <see cref="IHostConnectivityProbe"/>.
|
||||
/// </summary>
|
||||
public ModbusProbeOptions Probe { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Maximum registers per FC03 (Read Holding Registers) / FC04 (Read Input Registers)
|
||||
/// transaction. Modbus-TCP spec allows 125; many device families impose lower caps:
|
||||
/// AutomationDirect DL205/DL260 cap at <c>128</c>, Mitsubishi Q/FX3U cap at <c>64</c>,
|
||||
/// Omron CJ/CS cap at <c>125</c>. Set to the lowest cap across the devices this driver
|
||||
/// instance talks to; the driver auto-chunks larger reads into consecutive requests.
|
||||
/// Default <c>125</c> — the spec maximum, safe against any conforming server. Setting
|
||||
/// to <c>0</c> disables the cap (discouraged — the spec upper bound still applies).
|
||||
/// </summary>
|
||||
public ushort MaxRegistersPerRead { get; init; } = 125;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum registers per FC16 (Write Multiple Registers) transaction. Spec maximum is
|
||||
/// <c>123</c>; DL205/DL260 cap at <c>100</c>. Matching caller-vs-device semantics:
|
||||
/// exceeding the cap currently throws (writes aren't auto-chunked because a partial
|
||||
/// write across two FC16 calls is no longer atomic — caller must explicitly opt in
|
||||
/// by shortening the tag's <c>StringLength</c> or splitting it into multiple tags).
|
||||
/// </summary>
|
||||
public ushort MaxRegistersPerWrite { get; init; } = 123;
|
||||
}
|
||||
|
||||
public sealed class ModbusProbeOptions
|
||||
@@ -55,6 +75,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 +89,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 }
|
||||
|
||||
@@ -82,6 +109,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>
|
||||
@@ -95,3 +134,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,
|
||||
}
|
||||
|
||||
@@ -28,10 +28,20 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
_client = new TcpClient();
|
||||
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
|
||||
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
|
||||
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
|
||||
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
|
||||
// dialing the IPv4 address directly sidesteps that.
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
|
||||
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
|
||||
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
|
||||
|
||||
_client = new TcpClient(target.AddressFamily);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(_host, _port, cts.Token).ConfigureAwait(false);
|
||||
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,64 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies DL205/DL260 CDAB word ordering for 32-bit floats against the
|
||||
/// <c>dl205.json</c> pymodbus profile. DirectLOGIC stores IEEE-754 singles with the low
|
||||
/// word at the lower register address (CDAB) rather than the high word (ABCD). Reading
|
||||
/// <c>HR[1056..1057]</c> with <see cref="ModbusByteOrder.BigEndian"/> produces a tiny
|
||||
/// denormal (~5.74e-41) instead of the intended 1.5f — a silent "value is 0" bug in the
|
||||
/// field unless the caller opts into <see cref="ModbusByteOrder.WordSwap"/>.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205FloatCdabQuirkTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_Float32_CDAB_decodes_1_5f_from_HR1056()
|
||||
{
|
||||
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[1056..1057]).");
|
||||
}
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("DL205_Float_CDAB",
|
||||
ModbusRegion.HoldingRegisters, Address: 1056,
|
||||
DataType: ModbusDataType.Float32, Writable: false,
|
||||
ByteOrder: ModbusByteOrder.WordSwap),
|
||||
// Control: same address, BigEndian — proves the default decode produces garbage.
|
||||
new ModbusTagDefinition("DL205_Float_ABCD",
|
||||
ModbusRegion.HoldingRegisters, Address: 1056,
|
||||
DataType: ModbusDataType.Float32, Writable: false,
|
||||
ByteOrder: ModbusByteOrder.BigEndian),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-cdab");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_Float_CDAB", "DL205_Float_ABCD"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(1.5f, "DL205 Float32 with WordSwap (CDAB) must decode HR[1056..1057] as 1.5f");
|
||||
|
||||
// The BigEndian read of the same wire bytes should differ — not asserting the exact
|
||||
// denormal value (that couples the test to IEEE-754 bit math) but the two decodes MUST
|
||||
// disagree, otherwise the word-order flag is a no-op.
|
||||
results[1].StatusCode.ShouldBe(0u);
|
||||
results[1].Value.ShouldNotBe(1.5f);
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
/// </remarks>
|
||||
public static class DL205Profile
|
||||
{
|
||||
/// <summary>Holding register the smoke test reads. Address 100 sidesteps the DL205
|
||||
/// register-zero quirk (pending confirmation) — see modbus-test-plan.md.</summary>
|
||||
public const ushort SmokeHoldingRegister = 100;
|
||||
/// <summary>
|
||||
/// Holding register the smoke test writes + reads. Address 200 is the first cell of the
|
||||
/// scratch HR range in both <c>Pymodbus/standard.json</c> (HR[200..209] = 0) and
|
||||
/// <c>Pymodbus/dl205.json</c> (HR[4096..4103] added in PR 43 for the same purpose), so
|
||||
/// the smoke test runs identically against either simulator profile. Originally
|
||||
/// targeted HR[100] — moved to HR[200] when the standard profile claimed HR[100] as
|
||||
/// the auto-incrementing register that drives subscribe-and-receive tests.
|
||||
/// </summary>
|
||||
public const ushort SmokeHoldingRegister = 200;
|
||||
|
||||
/// <summary>Expected value the pymodbus profile seeds into register 100. When running
|
||||
/// against a real DL205 (or a pymodbus profile where this register is writable), the smoke
|
||||
/// test seeds this value first, then reads it back.</summary>
|
||||
/// <summary>Value the smoke test writes then reads back to assert round-trip integrity.</summary>
|
||||
public const short SmokeHoldingValue = 1234;
|
||||
|
||||
public static ModbusDriverOptions BuildOptions(string host, int port) => new()
|
||||
@@ -32,7 +36,7 @@ public static class DL205Profile
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(
|
||||
Name: "DL205_Smoke_HReg100",
|
||||
Name: "Smoke_HReg200",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: SmokeHoldingRegister,
|
||||
DataType: ModbusDataType.Int16,
|
||||
|
||||
@@ -38,13 +38,13 @@ public sealed class DL205SmokeTests(ModbusSimulatorFixture sim)
|
||||
// zeroed at simulator start, and tests must not depend on prior-test state per the
|
||||
// test-plan conventions.
|
||||
var writeResults = await driver.WriteAsync(
|
||||
[new(FullReference: "DL205_Smoke_HReg100", Value: (short)DL205Profile.SmokeHoldingValue)],
|
||||
[new(FullReference: "Smoke_HReg200", Value: (short)DL205Profile.SmokeHoldingValue)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the ModbusPal DL205 profile");
|
||||
|
||||
var readResults = await driver.ReadAsync(
|
||||
["DL205_Smoke_HReg100"],
|
||||
["Smoke_HReg200"],
|
||||
TestContext.Current.CancellationToken);
|
||||
readResults.Count.ShouldBe(1);
|
||||
readResults[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,18 @@ public sealed class ModbusSimulatorFixture : IAsyncDisposable
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(Host, Port);
|
||||
// Force IPv4 family on the probe — pymodbus's TCP server binds 0.0.0.0 (IPv4 only)
|
||||
// while .NET's TcpClient default-resolves "localhost" → IPv6 ::1 first, fails to
|
||||
// connect, and only then tries IPv4. Under .NET 10 the IPv6 fail surfaces as a
|
||||
// 2s timeout (no graceful fallback by default), so the C# probe times out even
|
||||
// though a PowerShell probe of the same endpoint succeeds. Resolving + dialing
|
||||
// explicit IPv4 sidesteps the dual-stack ordering.
|
||||
using var client = new TcpClient(System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var task = client.ConnectAsync(
|
||||
System.Net.Dns.GetHostAddresses(Host)
|
||||
.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
?? System.Net.IPAddress.Loopback,
|
||||
Port);
|
||||
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
||||
{
|
||||
SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "DL205.json — AutomationDirect DirectLOGIC DL205/DL260 quirk simulator. Models each behavior in docs/v2/dl205.md as concrete register values so DL205_<behavior> integration tests can assert against this profile WITHOUT a live PLC. Loaded by `pymodbus.simulator`. See ../README.md. Per-quirk address layout matches the table in dl205.md exactly. `shared blocks: true` matches DL series behavior — coils/HR overlay the same word address space (a Y-output is both a discrete bit AND part of a system V-memory register).",
|
||||
"_comment": "DL205.json — DirectLOGIC DL205/DL260 quirk simulator. Models docs/v2/dl205.md as concrete register values. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live at top-level _comment + in README + git. Inline _quirk keys WITHIN individual register entries are accepted by pymodbus 3.13.0 (it only validates addr / value / action / parameters per entry). Each quirky uint16 is a pre-computed raw 16-bit value; pymodbus serves it verbatim. shared blocks=true matches DL series memory model. write list mirrors each seeded block — pymodbus rejects sweeping write ranges that include undefined cells.",
|
||||
|
||||
"server_list": {
|
||||
"srv": {
|
||||
@@ -27,15 +27,37 @@
|
||||
},
|
||||
"invalid": [],
|
||||
"write": [
|
||||
[0, 16383]
|
||||
[0, 0],
|
||||
[200, 209],
|
||||
[1024, 1024],
|
||||
[1040, 1042],
|
||||
[1056, 1057],
|
||||
[1072, 1072],
|
||||
[1280, 1282],
|
||||
[1343, 1343],
|
||||
[1407, 1407],
|
||||
[2048, 2050],
|
||||
[3072, 3074],
|
||||
[4000, 4007],
|
||||
[8448, 8448]
|
||||
],
|
||||
|
||||
"_comment_uint16": "Holding-register seeds. Every quirky value is a raw uint16 with the byte math worked out in dl205.md so the simulator serves it verbatim — pymodbus does NOT decode strings, BCD, or float-CDAB on its own; that's the driver's job.",
|
||||
|
||||
"uint16": [
|
||||
{"_quirk": "V0 marker. HR[0] = 0xCAFE proves register 0 is valid on DL205/DL260 (rejects-register-0 was a DL05/DL06 relative-mode artefact). 0xCAFE = 51966.",
|
||||
{"_quirk": "V0 marker. HR[0]=0xCAFE proves register 0 is valid on DL205/DL260 (rejects-register-0 was a DL05/DL06 relative-mode artefact). 0xCAFE = 51966.",
|
||||
"addr": 0, "value": 51966},
|
||||
|
||||
{"_quirk": "Scratch HR range 200..209 — mirrors the standard.json scratch range so the smoke test (DL205Profile.SmokeHoldingRegister=200) round-trips identically against either profile.",
|
||||
"addr": 200, "value": 0},
|
||||
{"addr": 201, "value": 0},
|
||||
{"addr": 202, "value": 0},
|
||||
{"addr": 203, "value": 0},
|
||||
{"addr": 204, "value": 0},
|
||||
{"addr": 205, "value": 0},
|
||||
{"addr": 206, "value": 0},
|
||||
{"addr": 207, "value": 0},
|
||||
{"addr": 208, "value": 0},
|
||||
{"addr": 209, "value": 0},
|
||||
|
||||
{"_quirk": "V2000 marker. V2000 octal = decimal 1024 = PDU 0x0400. Marker 0x2000 = 8192.",
|
||||
"addr": 1024, "value": 8192},
|
||||
|
||||
@@ -57,16 +79,14 @@
|
||||
{"_quirk": "BCD register. Decimal 1234 stored as BCD nibbles 0x1234 = 4660. NOT binary 1234 (= 0x04D2).",
|
||||
"addr": 1072, "value": 4660},
|
||||
|
||||
{"_quirk": "FC03 cap test. Real DL205/DL260 FC03 caps at 128 registers (above spec's 125). HR[1280..1407] is 128 contiguous registers; rest of block defaults to 0.",
|
||||
{"_quirk": "FC03 cap test marker — first cell of a 128-register span the FC03 cap test reads. Other cells in the span aren't seeded explicitly, so reads of HR[1283..1342] / 1344..1406 return the default 0; the seeded markers at 1280, 1281, 1282, 1343, 1407 prove the span boundaries.",
|
||||
"addr": 1280, "value": 0},
|
||||
{"addr": 1281, "value": 1},
|
||||
{"addr": 1282, "value": 2},
|
||||
{"addr": 1343, "value": 63, "_marker": "FC03Block_mid"},
|
||||
{"addr": 1407, "value": 127, "_marker": "FC03Block_last"}
|
||||
{"addr": 1343, "value": 63},
|
||||
{"addr": 1407, "value": 127}
|
||||
],
|
||||
|
||||
"_comment_bits": "Coils — Y outputs at 2048+, C relays at 3072+, scratch C at 4000-4007 for write tests. DL260 X inputs would be at discrete-input addresses 0..511 but pymodbus's shared-blocks mode + same-table-as-coils means those would conflict with HR seeds; FC02 tests against this profile use a separate discrete-input block instead — that's why `di size` is large but the X-input markers live in `bits` only when `shared blocks=false`. Document trade-off in README.",
|
||||
|
||||
"bits": [
|
||||
{"_quirk": "Y0 marker. DL260 maps Y0 to coil 2048 (0-based). Coil 2048 = ON proves the mapping.",
|
||||
"addr": 2048, "value": 1},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "Standard.json — generic Modbus TCP server for the integration suite. Loaded by `pymodbus.simulator`. See ../README.md for the launch command. Holding registers 0..31 are seeded with their address as value (HR[5]=5) for easy mental-map diagnostics. HR[100] auto-increments via pymodbus's built-in `increment` action so subscribe-and-receive integration tests have a register that ticks without a write. HR[200..209] is a scratch range left at 0 for write-roundtrip tests. Coils 0..31 alternate on/off (even=on); coils 100..109 scratch.",
|
||||
"_comment": "Standard.json — generic Modbus TCP server for the integration suite. See ../README.md. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history. Layout: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. Coils live at 1024+ because pymodbus stores all 4 standard tables in ONE underlying cell array — bits and uint16 at the same address conflict (each cell can only be typed once).",
|
||||
|
||||
"server_list": {
|
||||
"srv": {
|
||||
@@ -14,11 +14,11 @@
|
||||
"device_list": {
|
||||
"dev": {
|
||||
"setup": {
|
||||
"co size": 1024,
|
||||
"di size": 1024,
|
||||
"hr size": 1024,
|
||||
"ir size": 1024,
|
||||
"shared blocks": false,
|
||||
"co size": 2048,
|
||||
"di size": 2048,
|
||||
"hr size": 2048,
|
||||
"ir size": 2048,
|
||||
"shared blocks": true,
|
||||
"type exception": false,
|
||||
"defaults": {
|
||||
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
|
||||
@@ -27,26 +27,11 @@
|
||||
},
|
||||
"invalid": [],
|
||||
"write": [
|
||||
[0, 1023]
|
||||
],
|
||||
|
||||
"bits": [
|
||||
{"addr": 0, "value": 1}, {"addr": 1, "value": 0},
|
||||
{"addr": 2, "value": 1}, {"addr": 3, "value": 0},
|
||||
{"addr": 4, "value": 1}, {"addr": 5, "value": 0},
|
||||
{"addr": 6, "value": 1}, {"addr": 7, "value": 0},
|
||||
{"addr": 8, "value": 1}, {"addr": 9, "value": 0},
|
||||
{"addr": 10, "value": 1}, {"addr": 11, "value": 0},
|
||||
{"addr": 12, "value": 1}, {"addr": 13, "value": 0},
|
||||
{"addr": 14, "value": 1}, {"addr": 15, "value": 0},
|
||||
{"addr": 16, "value": 1}, {"addr": 17, "value": 0},
|
||||
{"addr": 18, "value": 1}, {"addr": 19, "value": 0},
|
||||
{"addr": 20, "value": 1}, {"addr": 21, "value": 0},
|
||||
{"addr": 22, "value": 1}, {"addr": 23, "value": 0},
|
||||
{"addr": 24, "value": 1}, {"addr": 25, "value": 0},
|
||||
{"addr": 26, "value": 1}, {"addr": 27, "value": 0},
|
||||
{"addr": 28, "value": 1}, {"addr": 29, "value": 0},
|
||||
{"addr": 30, "value": 1}, {"addr": 31, "value": 0}
|
||||
[0, 31],
|
||||
[100, 100],
|
||||
[200, 209],
|
||||
[1024, 1055],
|
||||
[1100, 1109]
|
||||
],
|
||||
|
||||
"uint16": [
|
||||
@@ -69,7 +54,38 @@
|
||||
|
||||
{"addr": 100, "value": 0,
|
||||
"action": "increment",
|
||||
"parameters": {"minval": 0, "maxval": 65535}}
|
||||
"parameters": {"minval": 0, "maxval": 65535}},
|
||||
|
||||
{"addr": 200, "value": 0}, {"addr": 201, "value": 0},
|
||||
{"addr": 202, "value": 0}, {"addr": 203, "value": 0},
|
||||
{"addr": 204, "value": 0}, {"addr": 205, "value": 0},
|
||||
{"addr": 206, "value": 0}, {"addr": 207, "value": 0},
|
||||
{"addr": 208, "value": 0}, {"addr": 209, "value": 0}
|
||||
],
|
||||
|
||||
"bits": [
|
||||
{"addr": 1024, "value": 1}, {"addr": 1025, "value": 0},
|
||||
{"addr": 1026, "value": 1}, {"addr": 1027, "value": 0},
|
||||
{"addr": 1028, "value": 1}, {"addr": 1029, "value": 0},
|
||||
{"addr": 1030, "value": 1}, {"addr": 1031, "value": 0},
|
||||
{"addr": 1032, "value": 1}, {"addr": 1033, "value": 0},
|
||||
{"addr": 1034, "value": 1}, {"addr": 1035, "value": 0},
|
||||
{"addr": 1036, "value": 1}, {"addr": 1037, "value": 0},
|
||||
{"addr": 1038, "value": 1}, {"addr": 1039, "value": 0},
|
||||
{"addr": 1040, "value": 1}, {"addr": 1041, "value": 0},
|
||||
{"addr": 1042, "value": 1}, {"addr": 1043, "value": 0},
|
||||
{"addr": 1044, "value": 1}, {"addr": 1045, "value": 0},
|
||||
{"addr": 1046, "value": 1}, {"addr": 1047, "value": 0},
|
||||
{"addr": 1048, "value": 1}, {"addr": 1049, "value": 0},
|
||||
{"addr": 1050, "value": 1}, {"addr": 1051, "value": 0},
|
||||
{"addr": 1052, "value": 1}, {"addr": 1053, "value": 0},
|
||||
{"addr": 1054, "value": 1}, {"addr": 1055, "value": 0},
|
||||
|
||||
{"addr": 1100, "value": 0}, {"addr": 1101, "value": 0},
|
||||
{"addr": 1102, "value": 0}, {"addr": 1103, "value": 0},
|
||||
{"addr": 1104, "value": 0}, {"addr": 1105, "value": 0},
|
||||
{"addr": 1106, "value": 0}, {"addr": 1107, "value": 0},
|
||||
{"addr": 1108, "value": 0}, {"addr": 1109, "value": 0}
|
||||
],
|
||||
|
||||
"uint32": [],
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
165
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCapTests.cs
Normal file
165
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCapTests.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusCapTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Records every PDU sent so tests can assert request-count and per-request quantity —
|
||||
/// the only observable behaviour of the auto-chunking path.
|
||||
/// </summary>
|
||||
private sealed class RecordingTransport : IModbusTransport
|
||||
{
|
||||
public readonly ushort[] HoldingRegisters = new ushort[1024];
|
||||
public readonly List<(ushort Address, ushort Quantity)> Fc03Requests = new();
|
||||
public readonly List<(ushort Address, ushort Quantity)> Fc16Requests = new();
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var fc = pdu[0];
|
||||
if (fc == 0x03)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
Fc03Requests.Add((addr, qty));
|
||||
var byteCount = (byte)(qty * 2);
|
||||
var resp = new byte[2 + byteCount];
|
||||
resp[0] = 0x03;
|
||||
resp[1] = byteCount;
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF);
|
||||
}
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
if (fc == 0x10)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
Fc16Requests.Add((addr, qty));
|
||||
for (var i = 0; i < qty; i++)
|
||||
HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
|
||||
return Task.FromResult(new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] });
|
||||
}
|
||||
return Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} unsupported"));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_within_cap_issues_single_FC03_request()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 40); // 20 regs — fits in default cap (125).
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
_ = await drv.ReadAsync(["S"], TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Fc03Requests.Count.ShouldBe(1);
|
||||
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_above_cap_splits_into_two_FC03_requests()
|
||||
{
|
||||
// 240-char string = 120 regs. Cap = 100 (a typical sub-spec device cap). Expect 100 + 20.
|
||||
var tag = new ModbusTagDefinition("LongString", ModbusRegion.HoldingRegisters, 100, ModbusDataType.String,
|
||||
StringLength: 240);
|
||||
var transport = new RecordingTransport();
|
||||
// Seed cells so the re-assembled payload is stable — confirms chunks are stitched in order.
|
||||
for (ushort i = 100; i < 100 + 120; i++)
|
||||
transport.HoldingRegisters[i] = (ushort)((('A' + (i - 100) % 26) << 8) | ('A' + (i - 100) % 26));
|
||||
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "fake",
|
||||
Tags = [tag],
|
||||
MaxRegistersPerRead = 100,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.ReadAsync(["LongString"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
transport.Fc03Requests.Count.ShouldBe(2, "120 regs / cap 100 → 2 requests");
|
||||
transport.Fc03Requests[0].ShouldBe(((ushort)100, (ushort)100));
|
||||
transport.Fc03Requests[1].ShouldBe(((ushort)200, (ushort)20));
|
||||
|
||||
// Payload continuity: re-assembled string starts where register 100 does and keeps going.
|
||||
var s = (string)results[0].Value!;
|
||||
s.Length.ShouldBeGreaterThan(0);
|
||||
s[0].ShouldBe('A'); // register[100] high byte
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_cap_honors_Mitsubishi_lower_cap_of_64()
|
||||
{
|
||||
// 200-char string = 100 regs. Mitsubishi Q cap = 64. Expect: 64, 36.
|
||||
var tag = new ModbusTagDefinition("MitString", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 200);
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerRead = 64, Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
_ = await drv.ReadAsync(["MitString"], TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Fc03Requests.Count.ShouldBe(2);
|
||||
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)64);
|
||||
transport.Fc03Requests[1].Quantity.ShouldBe((ushort)36);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_exceeding_cap_throws_instead_of_splitting()
|
||||
{
|
||||
// Partial FC16 across two transactions is not atomic. Forcing an explicit exception so the
|
||||
// caller knows their tag definition is incompatible with the device cap rather than silently
|
||||
// writing half a string and crashing between chunks.
|
||||
var tag = new ModbusTagDefinition("LongStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 220); // 110 regs.
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("LongStringWrite", new string('A', 220))],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Driver catches the internal exception and surfaces BadInternalError — the Fc16Requests
|
||||
// list must still be empty because nothing was sent.
|
||||
results[0].StatusCode.ShouldNotBe(0u);
|
||||
transport.Fc16Requests.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_within_cap_proceeds_normally()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("ShortStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 40); // 20 regs.
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("ShortStringWrite", "HELLO")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
transport.Fc16Requests.Count.ShouldBe(1);
|
||||
transport.Fc16Requests[0].Quantity.ShouldBe((ushort)20);
|
||||
}
|
||||
}
|
||||
@@ -172,4 +172,144 @@ 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");
|
||||
}
|
||||
|
||||
// --- 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