using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; using Mbproxy; using Mbproxy.Proxy; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using NModbus; using Serilog; using Serilog.Core; using Serilog.Events; using Shouldly; using Xunit; namespace Mbproxy.Tests.Proxy; /// /// End-to-end tests for the BCD rewriter pipeline against the pymodbus DL205 simulator. /// /// Each test starts an in-process proxy host configured to point at the simulator, /// connects an NModbus client to the proxy's listen port, and asserts bidirectional /// BCD rewriting behaviour. /// /// All tests skip gracefully when the simulator is unavailable (Python / pymodbus missing). /// [Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))] [Trait("Category", "E2E")] public sealed class RewriterE2ETests { private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim; public RewriterE2ETests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim) { _sim = sim; } // ── 1. FC03 HR1072 with BCD configured → decoded 1234 ──────────────────── /// /// Configure a 16-bit BCD tag at address 1072 (seeded 0x1234 in the simulator). /// The proxy should decode the BCD nibbles and return binary 1234 to the client. /// [Fact(Timeout = 5_000)] public async Task Read_HR1072_AsBcd_ReturnsDecoded_1234() { if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd16Addresses: [1072]); await using var _ = new AsyncHostDispose(host, cts); using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var master = new ModbusFactory().CreateMaster(client); ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1); // Simulator stores 0x1234 = raw BCD. Proxy should decode → 1234 decimal. regs[0].ShouldBe((ushort)1234); } // ── 2. FC03 HR1072 without BCD configured → raw 0x1234 ─────────────────── /// /// Same address, no BCD tags configured. The proxy passes the raw BCD nibbles through. /// Verifies the rewriter is opt-in per tag. /// [Fact(Timeout = 5_000)] public async Task Read_HR1072_AsRaw_WhenNotConfigured_Returns_0x1234() { if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); // Empty BCD tag list — no rewriting. var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd16Addresses: []); await using var _ = new AsyncHostDispose(host, cts); using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var master = new ModbusFactory().CreateMaster(client); ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1); // Raw BCD nibbles pass through unchanged. regs[0].ShouldBe((ushort)0x1234); } // ── 3. FC06 write BCD → simulator stores encoded nibbles ──────────────── /// /// Configure a 16-bit BCD tag at address 200 (in the simulator's writable scratch range). /// Write decimal 9876 through the proxy; read back raw from the simulator and expect 0x9876. /// [Fact(Timeout = 5_000)] public async Task Write_HR200_AsBcd_StoresEncoded_0x9876() { if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd16Addresses: [200]); await using var _ = new AsyncHostDispose(host, cts); // Write through the proxy (client side: binary 9876). using var proxyClient = new TcpClient(); await proxyClient.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var proxyMaster = new ModbusFactory().CreateMaster(proxyClient); proxyMaster.WriteSingleRegister(slaveAddress: 1, registerAddress: 200, value: 9876); // Read raw from the simulator directly (bypassing the proxy). using var simClient = new TcpClient(); await simClient.ConnectAsync(_sim.Host, _sim.Port, TestContext.Current.CancellationToken); var simMaster = new ModbusFactory().CreateMaster(simClient); ushort[] raw = simMaster.ReadHoldingRegisters(slaveAddress: 1, startAddress: 200, numberOfPoints: 1); // Simulator should store BCD-encoded 9876 = 0x9876. raw[0].ShouldBe((ushort)0x9876); } // ── 4. FC03 read 32-bit BCD pair at HR1072/HR1073 (CDAB) ──────────────── /// /// Reads a 32-bit BCD pair at address 1072/1073 (CDAB layout). /// Simulator seeds: 1072=0x1234 (low word), 1073=0x0000 (high word). /// Decoded = 0*10000 + 1234 = 1234. /// This verifies the CDAB word order is handled end-to-end. /// [Fact(Timeout = 5_000)] public async Task Read_HR1072_HR1073_AsBcd32_ReturnsDecoded_From_CDAB() { if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd32Addresses: [1072]); await using var _ = new AsyncHostDispose(host, cts); using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var master = new ModbusFactory().CreateMaster(client); // Read both registers of the 32-bit pair. ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 2); // After decoding: low 4 digits = 1234, high 4 digits = 0 // The proxy returns decoded binary values in CDAB order: // regs[0] = low 4 decoded digits = 1234 // regs[1] = high 4 decoded digits = 0 regs[0].ShouldBe((ushort)1234); // decoded low 4 digits regs[1].ShouldBe((ushort)0); // decoded high 4 digits } // ── 5. Partial FC03 on high register of 32-bit pair → raw + warning ────── /// /// Read only the high register (1073) of a 32-bit BCD pair at 1072/1073. /// The proxy cannot decode a partial pair — it should pass through raw and log /// mbproxy.rewrite.partial_bcd. /// [Fact(Timeout = 5_000)] public async Task Partial_FC03_OnHighRegisterOf_32BitPair_PassesThroughRaw_AndLogsWarning() { if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); var sink = new CapturingSink(); var serilog = new LoggerConfiguration() .MinimumLevel.Warning() .WriteTo.Sink(sink) .CreateLogger(); var (proxyPort, host, cts) = await StartBcdProxyAsync( bcd32Addresses: [1072], serilogOverride: serilog); await using var _ = new AsyncHostDispose(host, cts); using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var master = new ModbusFactory().CreateMaster(client); // Read only the high register (1073) — partial overlap for the 32-bit pair. ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1073, numberOfPoints: 1); // The raw simulator value for HR1073 is 0x0000 (high word of the 32-bit pair). regs[0].ShouldBe((ushort)0x0000); // raw passthrough // The partial_bcd warning should have been logged. var partialEvents = sink.Events .Where(e => e.MessageTemplate.Text.Contains("mbproxy.rewrite.partial_bcd") || e.MessageTemplate.Text.Contains("Partial BCD overlap")) .ToList(); partialEvents.ShouldNotBeEmpty("Expected mbproxy.rewrite.partial_bcd warning to be logged"); } // ── 6. MBAP TxId preserved after rewriting (20 consecutive) ───────────── /// /// Issues 20 consecutive FC03 reads with manually-incremented TxIds through a proxy /// that has BCD rewriting active (tag at 1072). Verifies the MBAP header is never /// tampered with by the rewriter. /// [Fact(Timeout = 5_000)] public async Task MbapTxId_StillPreserved_AfterRewriting_20Consecutive() { if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd16Addresses: [1072]); await using var _ = new AsyncHostDispose(host, cts); using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.NoDelay = true; await socket.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); const int count = 20; byte[] reqBuf = new byte[12]; // FC03 request frame byte[] rspBuf = new byte[260]; for (ushort txId = 1; txId <= count; txId++) { // Build FC03 request: read 1 register at address 1072. reqBuf[0] = (byte)(txId >> 8); reqBuf[1] = (byte)(txId & 0xFF); reqBuf[2] = 0x00; reqBuf[3] = 0x00; reqBuf[4] = 0x00; reqBuf[5] = 0x06; // Length reqBuf[6] = 0x01; // UnitId reqBuf[7] = 0x03; // FC03 reqBuf[8] = 0x04; // Start addr high (1072 = 0x0430) reqBuf[9] = 0x30; // Start addr low reqBuf[10] = 0x00; reqBuf[11] = 0x01; // Qty = 1 await socket.SendAsync(reqBuf.AsMemory(), SocketFlags.None, TestContext.Current.CancellationToken); // Read 7-byte response header. int read = 0; while (read < 7) read += await socket.ReceiveAsync(rspBuf.AsMemory(read, 7 - read), SocketFlags.None, TestContext.Current.CancellationToken); ushort rspTxId = (ushort)((rspBuf[0] << 8) | rspBuf[1]); ushort rspLength = (ushort)((rspBuf[4] << 8) | rspBuf[5]); rspTxId.ShouldBe(txId, $"TxId mismatch on iteration {txId}"); // Drain the body. int bodyLen = rspLength - 1; if (bodyLen > 0) { int bodyRead = 0; while (bodyRead < bodyLen) bodyRead += await socket.ReceiveAsync(rspBuf.AsMemory(7 + bodyRead, bodyLen - bodyRead), SocketFlags.None, TestContext.Current.CancellationToken); } } } // ── 7. FC16 with 16-bit BCD in middle of write range ──────────────────── /// /// FC16 (Write Multiple Registers) covering a 3-register span where only the middle /// register is a configured BCD tag. The proxy must encode the middle slot and leave /// the flanks untouched. Verifies per-register selectivity within a multi-register write. /// [Fact(Timeout = 5_000)] public async Task Write_FC16_With_Bcd16_InRange_StoresEncoded_AtOnlyTheBcdSlot() { if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); // Configure a 16-bit BCD tag at the middle register of a 3-register write. var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd16Addresses: [205]); await using var _ = new AsyncHostDispose(host, cts); // FC16 write to HR204..HR206 with binary values [10, 9876, 20]. using var proxyClient = new TcpClient(); await proxyClient.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var proxyMaster = new ModbusFactory().CreateMaster(proxyClient); proxyMaster.WriteMultipleRegisters(slaveAddress: 1, startAddress: 204, data: new ushort[] { 10, 9876, 20 }); // Read raw from the simulator directly. using var simClient = new TcpClient(); await simClient.ConnectAsync(_sim.Host, _sim.Port, TestContext.Current.CancellationToken); var simMaster = new ModbusFactory().CreateMaster(simClient); ushort[] raw = simMaster.ReadHoldingRegisters(slaveAddress: 1, startAddress: 204, numberOfPoints: 3); raw[0].ShouldBe((ushort)10, "HR204 is not a BCD tag — must pass through unchanged"); raw[1].ShouldBe((ushort)0x9876, "HR205 is a 16-bit BCD tag — must be re-encoded to nibbles"); raw[2].ShouldBe((ushort)20, "HR206 is not a BCD tag — must pass through unchanged"); } // ── 8. FC16 with 32-bit BCD pair → both halves CDAB-encoded ───────────── /// /// FC16 covering both halves of a configured 32-bit BCD pair. The pipeline reconstructs /// the binary integer from the CDAB-ordered registers (binaryValue = high * 10000 + low), /// encodes it as a BCD pair, and writes back in CDAB order. /// /// Example: client writes [low=5678, high=1234] → binaryValue = 12345678 /// → Encode32(12345678) = (bcdLow=0x5678, bcdHigh=0x1234) /// [Fact(Timeout = 5_000)] public async Task Write_FC16_With_Bcd32Pair_StoresCdabEncoded() { if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); // Configure a 32-bit BCD tag spanning HR207 + HR208 (both in [200, 209] scratch range). var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd32Addresses: [207]); await using var _ = new AsyncHostDispose(host, cts); // FC16 write of [low=5678, high=1234] → decimal 12345678. using var proxyClient = new TcpClient(); await proxyClient.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var proxyMaster = new ModbusFactory().CreateMaster(proxyClient); proxyMaster.WriteMultipleRegisters(slaveAddress: 1, startAddress: 207, data: new ushort[] { 5678, 1234 }); using var simClient = new TcpClient(); await simClient.ConnectAsync(_sim.Host, _sim.Port, TestContext.Current.CancellationToken); var simMaster = new ModbusFactory().CreateMaster(simClient); ushort[] raw = simMaster.ReadHoldingRegisters(slaveAddress: 1, startAddress: 207, numberOfPoints: 2); raw[0].ShouldBe((ushort)0x5678, "HR207 (low word of CDAB pair) must hold low 4 BCD digits"); raw[1].ShouldBe((ushort)0x1234, "HR208 (high word of CDAB pair) must hold high 4 BCD digits"); } // ── 9. FC16 partial overlap on 32-bit pair → raw + warning ────────────── /// /// FC16 writes only the LOW register of a configured 32-bit BCD pair (qty=1 at the low /// address). The pipeline cannot safely encode half of a 32-bit value, so it passes the /// register through raw and logs mbproxy.rewrite.partial_bcd. /// [Fact(Timeout = 5_000)] public async Task Write_FC16_PartialBcd32_OnLowAddressOnly_PassesThroughRaw_AndLogsWarning() { if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); var sink = new CapturingSink(); var serilog = new LoggerConfiguration() .MinimumLevel.Warning() .WriteTo.Sink(sink) .CreateLogger(); // Configure a 32-bit BCD tag at HR207 + HR208 (pair). var (proxyPort, host, cts) = await StartBcdProxyAsync( bcd32Addresses: [207], serilogOverride: serilog); await using var _ = new AsyncHostDispose(host, cts); // FC16 write of [42] to HR207 only — partial overlap on the 32-bit pair. using var proxyClient = new TcpClient(); await proxyClient.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var proxyMaster = new ModbusFactory().CreateMaster(proxyClient); proxyMaster.WriteMultipleRegisters(slaveAddress: 1, startAddress: 207, data: new ushort[] { 42 }); // Simulator should hold the raw value 42 (no rewriting on partial overlap). using var simClient = new TcpClient(); await simClient.ConnectAsync(_sim.Host, _sim.Port, TestContext.Current.CancellationToken); var simMaster = new ModbusFactory().CreateMaster(simClient); ushort[] raw = simMaster.ReadHoldingRegisters(slaveAddress: 1, startAddress: 207, numberOfPoints: 1); raw[0].ShouldBe((ushort)42, "Partial-overlap write must pass through raw (not BCD-encoded)"); // The partial_bcd warning must have been logged. var partialEvents = sink.Events .Where(e => e.MessageTemplate.Text.Contains("mbproxy.rewrite.partial_bcd") || e.MessageTemplate.Text.Contains("Partial BCD overlap")) .ToList(); partialEvents.ShouldNotBeEmpty("Expected mbproxy.rewrite.partial_bcd warning on partial FC16 write"); } // ── Helpers ────────────────────────────────────────────────────────────── private async Task<(int proxyPort, IHost host, CancellationTokenSource cts)> StartBcdProxyAsync( ushort[]? bcd16Addresses = null, ushort[]? bcd32Addresses = null, Serilog.ILogger? serilogOverride = null) { int proxyPort = PickFreePort(); var config = new Dictionary { ["Mbproxy:AdminPort"] = "8080", ["Mbproxy:Plcs:0:Name"] = "TestPLC", ["Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(), ["Mbproxy:Plcs:0:Host"] = _sim.Host, ["Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(), ["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000", ["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000", }; // Add BCD tag entries to the in-memory config. int tagIndex = 0; foreach (ushort addr in bcd16Addresses ?? []) { config[$"Mbproxy:BcdTags:Global:{tagIndex}:Address"] = addr.ToString(); config[$"Mbproxy:BcdTags:Global:{tagIndex}:Width"] = "16"; tagIndex++; } foreach (ushort addr in bcd32Addresses ?? []) { config[$"Mbproxy:BcdTags:Global:{tagIndex}:Address"] = addr.ToString(); config[$"Mbproxy:BcdTags:Global:{tagIndex}:Width"] = "32"; tagIndex++; } using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var host = BuildBcdProxyHost(config, serilogOverride); await host.StartAsync(startCts.Token); await Task.Delay(150, TestContext.Current.CancellationToken); var runCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); return (proxyPort, host, runCts); } private static IHost BuildBcdProxyHost( Dictionary config, Serilog.ILogger? serilogOverride = null) { var builder = Host.CreateApplicationBuilder(); builder.Configuration.AddInMemoryCollection(config); var logger = serilogOverride ?? new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(); builder.Services.AddSerilog(logger, dispose: false); builder.AddMbproxyOptions(); // Use the real BcdPduPipeline (not NoopPduPipeline) for E2E rewriter tests. builder.Services.AddSingleton(); builder.Services.AddHostedService(); return builder.Build(); } private static int PickFreePort() { var l = new TcpListener(IPAddress.Loopback, 0); l.Start(); int port = ((IPEndPoint)l.LocalEndpoint).Port; l.Stop(); return port; } private sealed class AsyncHostDispose : IAsyncDisposable { private readonly IHost _host; private readonly CancellationTokenSource _cts; public AsyncHostDispose(IHost host, CancellationTokenSource cts) { _host = host; _cts = cts; } public async ValueTask DisposeAsync() { using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { await _host.StopAsync(stopCts.Token); } catch { /* best effort */ } _host.Dispose(); _cts.Dispose(); } } // ── Capturing log sink (shared with HostSmokeTests) ───────────────────── private sealed class CapturingSink : ILogEventSink { private readonly ConcurrentQueue _events = new(); public IEnumerable Events => _events; public void Emit(LogEvent logEvent) => _events.Enqueue(logEvent); } }