mbproxy: initial commit through Phase 9 (TxId multiplexing)
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>
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
using System.Collections.Frozen;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="BcdPduPipeline"/> using synthetic PDU byte arrays.
|
||||
/// No network, no simulator. Each test builds a hand-rolled <see cref="BcdTagMap"/>,
|
||||
/// calls <see cref="BcdPduPipeline.Process"/>, and asserts resulting bytes + counter deltas.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BcdPduPipelineTests
|
||||
{
|
||||
private static readonly BcdPduPipeline Pipeline = new();
|
||||
|
||||
// ── Factories ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="PerPlcContext"/> from a set of BcdTag entries.
|
||||
/// The context has a fresh <see cref="ProxyCounters"/> instance.
|
||||
/// </summary>
|
||||
private static PerPlcContext MakeContext(params BcdTag[] tags)
|
||||
{
|
||||
var frozen = tags
|
||||
.ToDictionary(t => t.Address)
|
||||
.ToFrozenDictionary();
|
||||
var map = frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;
|
||||
|
||||
return new PerPlcContext
|
||||
{
|
||||
PlcName = "TestPLC",
|
||||
TagMap = map,
|
||||
Counters = new ProxyCounters(),
|
||||
Logger = NullLogger.Instance,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 9: the rewriter consumes <see cref="PerPlcContext.CurrentRequest"/> rather
|
||||
/// than a per-pair last-request slot. Tests build a synthetic <see cref="InFlightRequest"/>
|
||||
/// to drive response decoding.
|
||||
/// </summary>
|
||||
private static InFlightRequest MakeInFlight(byte fc, ushort startAddress, ushort qty)
|
||||
=> new(
|
||||
UnitId: 1,
|
||||
Fc: fc,
|
||||
StartAddress: startAddress,
|
||||
Qty: qty,
|
||||
// Phase 9: always exactly one party. We don't have a real UpstreamPipe in
|
||||
// pipeline unit tests; the rewriter never dereferences the party list, so a
|
||||
// null-forgiving placeholder is safe.
|
||||
InterestedParties: Array.Empty<InterestedParty>(),
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
|
||||
/// <summary>FC03 response PDU: [fc=03][byteCount][reg0Hi][reg0Lo]...</summary>
|
||||
private static byte[] Fc03Response(params ushort[] registers)
|
||||
{
|
||||
var pdu = new byte[2 + registers.Length * 2];
|
||||
pdu[0] = 0x03;
|
||||
pdu[1] = (byte)(registers.Length * 2);
|
||||
for (int i = 0; i < registers.Length; i++)
|
||||
{
|
||||
pdu[2 + i * 2] = (byte)(registers[i] >> 8);
|
||||
pdu[2 + i * 2 + 1] = (byte)(registers[i] & 0xFF);
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
/// <summary>FC04 response PDU: same shape as FC03 but fc=04.</summary>
|
||||
private static byte[] Fc04Response(params ushort[] registers)
|
||||
{
|
||||
var pdu = Fc03Response(registers);
|
||||
pdu[0] = 0x04;
|
||||
return pdu;
|
||||
}
|
||||
|
||||
/// <summary>FC03 request PDU: [fc=03][addrHi][addrLo][qtyHi][qtyLo]</summary>
|
||||
private static byte[] Fc03Request(ushort address, ushort qty)
|
||||
=> [0x03, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(qty >> 8), (byte)(qty & 0xFF)];
|
||||
|
||||
/// <summary>FC06 request PDU: [fc=06][addrHi][addrLo][valHi][valLo]</summary>
|
||||
private static byte[] Fc06Request(ushort address, ushort value)
|
||||
=> [0x06, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(value >> 8), (byte)(value & 0xFF)];
|
||||
|
||||
/// <summary>FC16 request PDU: [fc=10][startHi][startLo][qtyHi][qtyLo][byteCount][reg data...]</summary>
|
||||
private static byte[] Fc16Request(ushort start, params ushort[] registers)
|
||||
{
|
||||
ushort qty = (ushort)registers.Length;
|
||||
var pdu = new byte[6 + registers.Length * 2];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(start >> 8);
|
||||
pdu[2] = (byte)(start & 0xFF);
|
||||
pdu[3] = (byte)(qty >> 8);
|
||||
pdu[4] = (byte)(qty & 0xFF);
|
||||
pdu[5] = (byte)(registers.Length * 2);
|
||||
for (int i = 0; i < registers.Length; i++)
|
||||
{
|
||||
pdu[6 + i * 2] = (byte)(registers[i] >> 8);
|
||||
pdu[6 + i * 2 + 1] = (byte)(registers[i] & 0xFF);
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulate sending an FC03/04 request then reading the response.
|
||||
/// Phase 9: builds an <see cref="InFlightRequest"/> matching the request and attaches
|
||||
/// it to the response-call context (replacing the per-pair last-request slot).
|
||||
/// </summary>
|
||||
private void SendRequestThenProcessResponse(
|
||||
PerPlcContext ctx,
|
||||
byte[] requestPdu,
|
||||
byte[] responsePdu)
|
||||
{
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, requestPdu.AsSpan(), ctx);
|
||||
|
||||
// Extract the request start/qty so we can build the InFlightRequest the multiplexer
|
||||
// would attach to the response call.
|
||||
byte fc = requestPdu[0];
|
||||
ushort start = 0, qty = 0;
|
||||
if (fc is 0x03 or 0x04 && requestPdu.Length >= 5)
|
||||
{
|
||||
start = (ushort)((requestPdu[1] << 8) | requestPdu[2]);
|
||||
qty = (ushort)((requestPdu[3] << 8) | requestPdu[4]);
|
||||
}
|
||||
|
||||
var responseCtx = ctx.WithCurrentRequest(MakeInFlight(fc, start, qty));
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, responsePdu.AsSpan(), responseCtx);
|
||||
}
|
||||
|
||||
// ── Helper to read a register pair from a response PDU ──────────────────
|
||||
|
||||
private static ushort ReadReg(byte[] pdu, int offsetWords)
|
||||
=> (ushort)((pdu[2 + offsetWords * 2] << 8) | pdu[2 + offsetWords * 2 + 1]);
|
||||
|
||||
// ── FC03 response tests ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC03_Single16BitBcd_AtReadAddress_DecodesNibbles()
|
||||
{
|
||||
// Raw wire value 0x1234 → decoded binary 1234
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var req = Fc03Request(100, 1);
|
||||
var rsp = Fc03Response(0x1234);
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)1234);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC03_Full32BitBcdPair_WithinReadRange_DecodesNibbles()
|
||||
{
|
||||
// 32-bit BCD pair at 100/101: low=0x5678 (5678), high=0x1234 (1234)
|
||||
// Decoded = 1234 * 10000 + 5678 = 12345678
|
||||
// Binary: low 4 digits = 5678, high 4 digits = 1234
|
||||
var ctx = MakeContext(BcdTag.Create(100, 32));
|
||||
var req = Fc03Request(100, 2);
|
||||
var rsp = Fc03Response(0x5678, 0x1234); // [0]=low, [1]=high
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)5678); // decoded low 4 digits
|
||||
ReadReg(rsp, 1).ShouldBe((ushort)1234); // decoded high 4 digits
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC03_Partial32Bit_LowOnly_qty1_AtLowAddr_PassesThroughRaw()
|
||||
{
|
||||
// Read qty=1 at the low address of a 32-bit pair — only half the pair is in range.
|
||||
var ctx = MakeContext(BcdTag.Create(100, 32));
|
||||
var req = Fc03Request(100, 1);
|
||||
var rsp = Fc03Response(0x5678);
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)0x5678); // unchanged
|
||||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC03_Partial32Bit_HighOnly_qty1_AtHighAddr_PassesThroughRaw()
|
||||
{
|
||||
// Read qty=1 starting at the HIGH register of a 32-bit pair (address 101 when tag is at 100).
|
||||
// TryGetForRange returns OffsetWords = -1 for the hit (low register is before the range).
|
||||
var ctx = MakeContext(BcdTag.Create(100, 32));
|
||||
var req = Fc03Request(101, 1); // only reading high register
|
||||
var rsp = Fc03Response(0x1234);
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)0x1234); // unchanged (partial overlap)
|
||||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC03_Mixed_16BitBcd_And_NonBcd_InSameRead_OnlyBcdSlotRewritten()
|
||||
{
|
||||
// Registers: [0]=non-BCD at addr 99, [1]=BCD 16-bit at addr 100, [2]=non-BCD at addr 101
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var req = Fc03Request(99, 3);
|
||||
var rsp = Fc03Response(0xABCD, 0x1234, 0xDEAD);
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)0xABCD); // non-BCD, unchanged
|
||||
ReadReg(rsp, 1).ShouldBe((ushort)1234); // BCD decoded
|
||||
ReadReg(rsp, 2).ShouldBe((ushort)0xDEAD); // non-BCD, unchanged
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC03_BadNibble_At16BitBcdSlot_PassesThroughRaw_AndIncrementsInvalidBcd()
|
||||
{
|
||||
// 0x12A4 has nibble 'A' which is not a valid BCD digit.
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var req = Fc03Request(100, 1);
|
||||
var rsp = Fc03Response(0x12A4);
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)0x12A4); // unchanged
|
||||
ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── FC04 response tests ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC04_Single16BitBcd_AtReadAddress_DecodesNibbles()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(200, 16));
|
||||
// FC04 request: same shape as FC03 but fc=04
|
||||
var req = new byte[] { 0x04, 0x00, 0xC8, 0x00, 0x01 }; // addr=200, qty=1
|
||||
var rsp = Fc04Response(0x9876);
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)9876);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ── FC06 request tests ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC06_Write16BitBcd_EncodesClientBinaryToNibbles()
|
||||
{
|
||||
// Client writes binary 1234 → PLC should receive BCD 0x1234
|
||||
var ctx = MakeContext(BcdTag.Create(300, 16));
|
||||
var pdu = Fc06Request(300, 1234); // client sends binary 1234
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
sentValue.ShouldBe((ushort)0x1234); // BCD nibbles
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC06_WriteToLowAddrOf32BitPair_PassesThroughRaw_WithPartialWarning()
|
||||
{
|
||||
// FC06 can only write 1 register; if the target is the LOW addr of a 32-bit pair,
|
||||
// that's a partial write — pass through raw.
|
||||
var ctx = MakeContext(BcdTag.Create(400, 32));
|
||||
var pdu = Fc06Request(400, 9999); // 400 is the low address of the 32-bit pair
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
sentValue.ShouldBe((ushort)9999); // unchanged (raw binary)
|
||||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC06_WriteToHighAddrOf32BitPair_PassesThroughRaw_WithPartialWarning()
|
||||
{
|
||||
// Writing to address 401 when the 32-bit pair is at 400/401 — high register only.
|
||||
var ctx = MakeContext(BcdTag.Create(400, 32));
|
||||
var pdu = Fc06Request(401, 0x1234); // 401 is the high address
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
sentValue.ShouldBe((ushort)0x1234); // unchanged
|
||||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC06_WriteValueOutsideRange_InvalidBcd_PassesThroughRaw()
|
||||
{
|
||||
// Binary 10000 cannot be represented as 4-digit BCD (max 9999).
|
||||
var ctx = MakeContext(BcdTag.Create(300, 16));
|
||||
var pdu = Fc06Request(300, 10000); // 10000 > 9999
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
sentValue.ShouldBe((ushort)10000); // raw passthrough
|
||||
ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── FC16 request tests ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC16_WriteSingle16BitBcd_InMultiWrite_EncodesBcdSlotOnly()
|
||||
{
|
||||
// Registers 500, 501, 502, 503: only 502 is a BCD tag.
|
||||
// Non-BCD registers should pass through unchanged.
|
||||
var ctx = MakeContext(BcdTag.Create(502, 16));
|
||||
var pdu = Fc16Request(500, 0x0010, 0x0020, 1234, 0x0040);
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
// Register at offset 0 (addr 500): unchanged
|
||||
ushort r0 = (ushort)((pdu[6] << 8) | pdu[7]);
|
||||
r0.ShouldBe((ushort)0x0010);
|
||||
|
||||
// Register at offset 2 (addr 502): binary 1234 → BCD 0x1234
|
||||
ushort r2 = (ushort)((pdu[10] << 8) | pdu[11]);
|
||||
r2.ShouldBe((ushort)0x1234);
|
||||
|
||||
// Register at offset 3 (addr 503): unchanged
|
||||
ushort r3 = (ushort)((pdu[12] << 8) | pdu[13]);
|
||||
r3.ShouldBe((ushort)0x0040);
|
||||
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC16_WriteFull32BitBcdPair_EncodesAsNibbles()
|
||||
{
|
||||
// 32-bit BCD pair at 600/601: client sends 12345678 as CDAB binary.
|
||||
// The proxy should encode to low=0x5678, high=0x1234.
|
||||
// Client sends: low-4-digits=5678, high-4-digits=1234 (in CDAB order)
|
||||
var ctx = MakeContext(BcdTag.Create(600, 32));
|
||||
// Client sends binary: low register = low 4 digits = 5678, high register = high 4 digits = 1234
|
||||
// But actually the pipeline needs to reconstruct the value:
|
||||
// decoded = clientHigh * 10000 + clientLow = 1234 * 10000 + 5678 = 12345678
|
||||
// Then encode: (bcdLow=0x5678, bcdHigh=0x1234)
|
||||
var pdu = Fc16Request(600, 5678, 1234); // [0]=low-word=5678, [1]=high-word=1234
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
// After encoding: low=BCD(5678)=0x5678, high=BCD(1234)=0x1234
|
||||
ushort sentLow = (ushort)((pdu[6] << 8) | pdu[7]);
|
||||
ushort sentHigh = (ushort)((pdu[8] << 8) | pdu[9]);
|
||||
sentLow.ShouldBe((ushort)0x5678);
|
||||
sentHigh.ShouldBe((ushort)0x1234);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC16_WritePartiallyOverlapping32BitPair_PassesThroughRaw_WithPartialWarning()
|
||||
{
|
||||
// Write range 700–701 (2 regs), but 32-bit BCD tag is at 701/702.
|
||||
// Only the low register (701) is in range; high register (702) is not.
|
||||
var ctx = MakeContext(BcdTag.Create(701, 32));
|
||||
var pdu = Fc16Request(700, 0xAAAA, 0xBBBB); // writes 700 and 701; tag needs 701 and 702
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
// The low register (at offset 1 in pdu, i.e., addr 701) should be unchanged.
|
||||
ushort r1 = (ushort)((pdu[8] << 8) | pdu[9]);
|
||||
r1.ShouldBe((ushort)0xBBBB);
|
||||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── Pass-through FCs ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC01_Request_IsPassedThroughUnchanged()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var pdu = new byte[] { 0x01, 0x00, 0x64, 0x00, 0x08 }; // FC01 read coils
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC02_Request_IsPassedThroughUnchanged()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var pdu = new byte[] { 0x02, 0x00, 0x64, 0x00, 0x08 }; // FC02 read discrete inputs
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC05_Request_IsPassedThroughUnchanged()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var pdu = new byte[] { 0x05, 0x00, 0x64, 0xFF, 0x00 }; // FC05 write single coil
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC15_Request_IsPassedThroughUnchanged()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var pdu = new byte[] { 0x0F, 0x00, 0x64, 0x00, 0x08, 0x01, 0xAB }; // FC15 write multiple coils
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── Exception response test ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC03_ExceptionResponse_PassesThroughRaw_LogsPassthrough_IncrementsBackendException()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
// Exception response: [fc|0x80=0x83][exceptionCode=02]
|
||||
var pdu = new byte[] { 0x83, 0x02 };
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original); // bytes unchanged
|
||||
ctx.Counters.Snapshot().BackendException02.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── Empty BcdTagMap tests ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void EmptyTagMap_FC03Response_ProducesZeroRewrites()
|
||||
{
|
||||
var ctx = MakeContext(/* no tags */);
|
||||
var req = Fc03Request(100, 3);
|
||||
var rsp = Fc03Response(0x1234, 0x5678, 0x9ABC);
|
||||
byte[] original = [..rsp];
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
rsp.ShouldBe(original);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyTagMap_FC06Request_ProducesZeroRewrites()
|
||||
{
|
||||
var ctx = MakeContext(/* no tags */);
|
||||
var pdu = Fc06Request(300, 1234);
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── Counter snapshot accuracy ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CounterSnapshot_ReflectsIncrementsExactly()
|
||||
{
|
||||
// Process 3 FC03 responses with one 16-bit BCD slot each, plus one bad-nibble.
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var req = Fc03Request(100, 1);
|
||||
var rsp = Fc03Response(0x1234);
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
}
|
||||
|
||||
// One with a bad nibble.
|
||||
{
|
||||
var req = Fc03Request(100, 1);
|
||||
var rsp = Fc03Response(0x12A4);
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
}
|
||||
|
||||
var snap = ctx.Counters.Snapshot();
|
||||
snap.RewrittenSlots.ShouldBe(3); // 3 successful decodes
|
||||
snap.InvalidBcdWarnings.ShouldBe(1); // 1 bad-nibble pass-through
|
||||
// PdusForwarded = 4 requests + 4 responses = 8
|
||||
snap.PdusForwarded.ShouldBe(8);
|
||||
snap.Fc03.ShouldBe(8); // both request and response increment by FC (request FC03)
|
||||
}
|
||||
|
||||
// ── PDU length invariant ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PduLength_IsNeverChangedByRewriting()
|
||||
{
|
||||
// Build a response with two 16-bit BCD tags. After rewriting, the PDU must be
|
||||
// exactly the same byte count as before.
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(101, 16));
|
||||
var req = Fc03Request(100, 2);
|
||||
var rsp = Fc03Response(0x1234, 0x5678);
|
||||
int originalLength = rsp.Length;
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
rsp.Length.ShouldBe(originalLength); // MBAP transparency contract
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ── FC counter tracking ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FcCounters_IncrementCorrectly_ForEachFunctionCode()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
|
||||
// FC03 request
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc03Request(100, 1).AsSpan(), ctx);
|
||||
// FC04 request
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x04, 0x00, 0x64, 0x00, 0x01 }.AsSpan(), ctx);
|
||||
// FC06 request
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc06Request(300, 1234).AsSpan(), ctx);
|
||||
// FC16 request
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc16Request(100, 0x1234).AsSpan(), ctx);
|
||||
// FC01 (Other)
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x01, 0x00, 0x00, 0x00, 0x01 }.AsSpan(), ctx);
|
||||
|
||||
var snap = ctx.Counters.Snapshot();
|
||||
snap.Fc03.ShouldBe(1);
|
||||
snap.Fc04.ShouldBe(1);
|
||||
snap.Fc06.ShouldBe(1);
|
||||
snap.Fc16.ShouldBe(1);
|
||||
snap.FcOther.ShouldBe(1);
|
||||
snap.PdusForwarded.ShouldBe(5);
|
||||
}
|
||||
|
||||
// ── Extra coverage: backend exception codes ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BackendExceptions_AllCodes_TrackSeparately()
|
||||
{
|
||||
var ctx = MakeContext();
|
||||
|
||||
// Codes 1–4 get individual counters; code 5 goes to Other.
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x81, 0x01 }.AsSpan(), ctx);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x81, 0x02 }.AsSpan(), ctx);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x81, 0x03 }.AsSpan(), ctx);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x81, 0x04 }.AsSpan(), ctx);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x81, 0x05 }.AsSpan(), ctx); // code 5 → Other
|
||||
|
||||
var snap = ctx.Counters.Snapshot();
|
||||
snap.BackendException01.ShouldBe(1);
|
||||
snap.BackendException02.ShouldBe(1);
|
||||
snap.BackendException03.ShouldBe(1);
|
||||
snap.BackendException04.ShouldBe(1);
|
||||
snap.BackendExceptionOther.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ── Plain PduContext (no BCD context) → no-op ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PlainPduContext_IsPassedThroughWithoutError()
|
||||
{
|
||||
// If a plain PduContext is passed (not PerPlcContext), the pipeline must
|
||||
// return cleanly without throwing, leaving bytes unchanged.
|
||||
var ctx = new PduContext { PlcName = "Test" };
|
||||
var pdu = Fc03Response(0x1234);
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user