56eee3c563
Adds the mbproxy service end-to-end. Phases 00-08 implement the production-ready single-listener / 1:1-backend transparent Modbus TCP proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260 fleet. Phase 9 replaces the connection layer with a single backend socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's 4-concurrent-client cap as an operational ceiling. Phase 9 additions of note: - PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap - InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing for Phase 10 read coalescing — do not collapse to a single field) - Per-request watchdog: surfaces Modbus exception 0x0B to upstream on BackendRequestTimeoutMs, defending against lost responses, dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed- request bug (its ServerRequestHandler.last_pdu state race) - Status DTO + HTML gain inFlight / maxInFlight / txIdWraps / disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md) Tests: 263 unit + 38 E2E. Multiplexer correctness under truly concurrent backend traffic is proved against a stub backend in PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus 3.13's single-PDU framer stays in known-good mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
6.6 KiB
C#
175 lines
6.6 KiB
C#
using Mbproxy.Proxy;
|
|
using Xunit;
|
|
|
|
namespace Mbproxy.Tests.Proxy;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="MbapFrame"/> header parsing and frame-length helpers.
|
|
/// All tests are pure in-memory; no network, no simulator required.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class MbapFrameTests
|
|
{
|
|
// ── 1. TryParseHeader — too-short buffers ────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void TryParseHeader_TooShort_ReturnsFalse()
|
|
{
|
|
// A buffer of only 6 bytes is one byte short of the 7-byte header.
|
|
byte[] buf = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06];
|
|
bool result = MbapFrame.TryParseHeader(buf, out _, out _, out _, out _);
|
|
Assert.False(result, "Buffer shorter than 7 bytes must return false.");
|
|
}
|
|
|
|
[Fact]
|
|
public void TryParseHeader_EmptyBuffer_ReturnsFalse()
|
|
{
|
|
bool result = MbapFrame.TryParseHeader(ReadOnlySpan<byte>.Empty, out _, out _, out _, out _);
|
|
Assert.False(result);
|
|
}
|
|
|
|
// ── 2. TryParseHeader — valid frame parses all fields ──────────────────────────────
|
|
|
|
[Fact]
|
|
public void TryParseHeader_ValidFrame_ParsesAllFields()
|
|
{
|
|
// TxId=0x0042, ProtocolId=0x0000, Length=0x0006, UnitId=0x01
|
|
byte[] header = [0x00, 0x42, 0x00, 0x00, 0x00, 0x06, 0x01];
|
|
|
|
bool ok = MbapFrame.TryParseHeader(header, out ushort txId, out ushort protocolId,
|
|
out ushort length, out byte unitId);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(0x0042, txId);
|
|
Assert.Equal(0x0000, protocolId);
|
|
Assert.Equal(6, length);
|
|
Assert.Equal(1, unitId);
|
|
}
|
|
|
|
// ── 3. Non-zero ProtocolId still parses (PLC's job to reject it) ─────────────────
|
|
|
|
[Fact]
|
|
public void TryParseHeader_ProtocolId_NotZero_StillParses()
|
|
{
|
|
// ProtocolId = 0x0001 (non-standard but we don't filter it).
|
|
byte[] header = [0x00, 0x01, 0x00, 0x01, 0x00, 0x06, 0xFF];
|
|
|
|
bool ok = MbapFrame.TryParseHeader(header, out _, out ushort protocolId, out _, out _);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(0x0001, protocolId);
|
|
}
|
|
|
|
// ── 4. TotalFrameLength — known good values ──────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void TotalFrameLength_LengthField7_Returns13()
|
|
{
|
|
// 6 fixed prefix bytes + 7 = 13
|
|
Assert.Equal(13, MbapFrame.TotalFrameLength(7));
|
|
}
|
|
|
|
[Fact]
|
|
public void TotalFrameLength_LengthFieldMax_Returns_LengthFieldPlus6()
|
|
{
|
|
// The formula is always lengthField + 6.
|
|
ushort max = ushort.MaxValue; // 65535
|
|
Assert.Equal(max + 6, MbapFrame.TotalFrameLength(max));
|
|
}
|
|
|
|
// ── 5. Round-trip: FC03 read-holding-registers request ───────────────────────────
|
|
|
|
[Fact]
|
|
public void RoundTrip_FC03_ReadHoldingRegisters_Request_ParsesCorrectly()
|
|
{
|
|
// FC03 request: TxId=1, ProtocolId=0, Length=6, UnitId=1, FC=0x03, Start=0x0430, Qty=0x0001
|
|
byte[] frame =
|
|
[
|
|
0x00, 0x01, // TxId = 1
|
|
0x00, 0x00, // ProtocolId = 0
|
|
0x00, 0x06, // Length = 6
|
|
0x01, // UnitId = 1
|
|
0x03, // FC 03
|
|
0x04, 0x30, // Start address = 0x0430 (decimal 1072)
|
|
0x00, 0x01, // Quantity = 1
|
|
];
|
|
|
|
bool ok = MbapFrame.TryParseHeader(frame.AsSpan(0, 7),
|
|
out ushort txId, out ushort protocolId, out ushort length, out byte unitId);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(1, txId);
|
|
Assert.Equal(0, protocolId);
|
|
Assert.Equal(6, length);
|
|
Assert.Equal(1, unitId);
|
|
|
|
// Total frame = 6 + length = 12 bytes
|
|
Assert.Equal(12, MbapFrame.TotalFrameLength(length));
|
|
Assert.Equal(frame.Length, MbapFrame.TotalFrameLength(length));
|
|
}
|
|
|
|
// ── 6. Round-trip: FC16 write-multiple-registers request ─────────────────────────
|
|
|
|
[Fact]
|
|
public void RoundTrip_FC16_WriteMultipleRegisters_ParsesCorrectly()
|
|
{
|
|
// FC16 request: TxId=5, ProtocolId=0, Length=11, UnitId=1
|
|
// FC=0x10, Start=0x00C8 (200), Qty=2, ByteCount=4, Data=[0x00,0x0A, 0x00,0x14]
|
|
byte[] frame =
|
|
[
|
|
0x00, 0x05, // TxId = 5
|
|
0x00, 0x00, // ProtocolId = 0
|
|
0x00, 0x0B, // Length = 11
|
|
0x01, // UnitId = 1
|
|
0x10, // FC 16
|
|
0x00, 0xC8, // Start address = 200
|
|
0x00, 0x02, // Quantity = 2
|
|
0x04, // Byte count = 4
|
|
0x00, 0x0A, // Register 200 = 10
|
|
0x00, 0x14, // Register 201 = 20
|
|
];
|
|
|
|
bool ok = MbapFrame.TryParseHeader(frame.AsSpan(0, 7),
|
|
out ushort txId, out _, out ushort length, out byte unitId);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(5, txId);
|
|
Assert.Equal(11, length);
|
|
Assert.Equal(1, unitId);
|
|
|
|
// Total frame = 6 + 11 = 17
|
|
Assert.Equal(17, MbapFrame.TotalFrameLength(length));
|
|
Assert.Equal(frame.Length, MbapFrame.TotalFrameLength(length));
|
|
}
|
|
|
|
// ── 7. Length < 2 — parsed but unusual (callers' responsibility) ───────────────────
|
|
|
|
[Fact]
|
|
public void TryParseHeader_LengthLessThan2_ParsedButUnusual()
|
|
{
|
|
// length=1 means only a UnitId byte follows the 6-byte prefix; PDU body = 0 bytes.
|
|
// The proxy does not reject this — that is the PLC's job. We parse and pass through.
|
|
byte[] header = [0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01];
|
|
|
|
bool ok = MbapFrame.TryParseHeader(header, out _, out _, out ushort length, out _);
|
|
|
|
Assert.True(ok, "Header with length=1 should still parse; the proxy does not validate length semantics.");
|
|
Assert.Equal(1, length);
|
|
|
|
// TotalFrameLength still returns 6 + length = 7 (header only, no PDU body).
|
|
Assert.Equal(7, MbapFrame.TotalFrameLength(length));
|
|
}
|
|
|
|
// ── 8. Exactly 7 bytes — boundary case ─────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void TryParseHeader_ExactlySevenBytes_ParsesOk()
|
|
{
|
|
byte[] header = [0xFF, 0xFE, 0x00, 0x00, 0x00, 0x06, 0x02];
|
|
bool ok = MbapFrame.TryParseHeader(header, out ushort txId, out _, out _, out byte unitId);
|
|
Assert.True(ok);
|
|
Assert.Equal(0xFFFE, txId);
|
|
Assert.Equal(2, unitId);
|
|
}
|
|
}
|