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>
12 KiB
Phase 04 — Rewriter integration
Replace NoopPduPipeline with the real BCD rewriter. After this phase, FC03/FC04 responses have their configured BCD slots decoded to binary integers on the way to the client, and FC06/FC16 requests have their configured BCD slots encoded to nibbles on the way to the PLC. Counters and warnings come online here.
Depends on: Phase 02 (codec + tag map), Phase 03 (plumbing + IPduPipeline).
Parallel-safe with: nothing (it integrates two prior phases' outputs).
Goal
Wire BcdTagMap + BcdCodec into the proxy at the single hook point IPduPipeline.Process(...). The rewriter is responsible for:
- FC03 / FC04 responses: re-encode every covered slot from raw nibbles into a binary integer.
- FC06 / FC16 requests: re-encode every covered slot from binary integer into raw BCD nibbles.
- Partial-overlap of 32-bit pairs: pass through raw, emit
mbproxy.rewrite.partial_bcdwarning, increment partial-overlap counter. - Bad BCD nibbles in a PLC response: pass through raw, emit
mbproxy.rewrite.invalid_bcd(new event in this phase) at Warning, increment invalid-bcd counter. NEVER throw out of the pipeline. - Increment per-pair counters for
pdus.forwarded,pdus.byFc,pdus.rewrittenSlots,pdus.partialBcdWarnings,pdus.invalidBcdWarnings.
The transparency contract holds: MBAP header bytes are untouched, length field is unchanged (re-encoded slots are the same byte width), TxId / unit ID flow through.
Outputs
src/Mbproxy/Proxy/BcdPduPipeline.cs # replaces NoopPduPipeline
src/Mbproxy/Proxy/PerPlcContext.cs # the per-PLC context (BcdTagMap + counters + logger)
src/Mbproxy/Proxy/ProxyCounters.cs # System.Threading.Interlocked counters
src/Mbproxy/Proxy/RewriterLogEvents.cs # [LoggerMessage] static partial methods
tests/Mbproxy.Tests/Proxy/BcdPduPipelineTests.cs # unit tests against synthetic PDU bytes
tests/Mbproxy.Tests/Proxy/RewriterE2ETests.cs # e2e against the simulator
Modifications:
src/Mbproxy/Proxy/PlcConnectionPair.cs— replacePduContext(placeholder from phase 03) withPerPlcContext. Counters increment inline. The pipeline call site is unchanged in shape; only the context type and pipeline registration differ.src/Mbproxy/Proxy/ProxyWorker.cs— build onePerPlcContextper configured PLC at startup (callsBcdTagMapBuilder.Buildand wraps the resulting map + a freshProxyCounters+ a per-PLC logger). Stash the contexts in aDictionary<string, PerPlcContext>keyed by PLC name.src/Mbproxy/Program.cs— registerBcdPduPipelineas theIPduPipelinesingleton; remove theNoopPduPipelineregistration. The phase 03NoopPduPipeline.csfile stays (it's useful in tests as a baseline) but is no longer wired in production.tests/Mbproxy.Tests/Proxy/ProxyForwardingTests.cs— update the testForward_FC03_HR1072_Returns_RawBCD_0x1234(which was a phase-03 baseline) to a new testForward_FC03_HR1072_Returns_Decoded_1234that asserts1234. The original raw-passthrough behaviour is preserved by configuring a PLC with NO BCD tags.
Tasks
ProxyCounters.cs—internal sealed classholdinglongfields accessed viaInterlocked.Increment/Interlocked.Read. Fields cover the per-PLC counter list from../design.md→ Status page → Per-PLC fields. Methods:void IncrementPdusForwarded(),void IncrementFcCount(byte fc),void AddRewrittenSlots(int n),void IncrementPartialBcd(),void IncrementInvalidBcd(),void IncrementBackendException(byte code),void AddBytes(long up, long down).CounterSnapshot Snapshot()— returns an immutable record with all the values; consumed by phase 07's status page.
PerPlcContext.cs—internal sealed classholdingstring PlcName,BcdTagMap TagMap,ProxyCounters Counters,ILogger Logger. Constructed once per PLC at startup; lifetime = lifetime of the listener.BcdPduPipeline.cs— implementsIPduPipeline. Behaviour per direction:RequestToBackend: inspect the PDU's function code byte (pdu[0]):- FC06: read
(address, value)frompdu[1..]. IfTagMap.TryGet(address)and Width=16, replace value bytes withBcdCodec.Encode16(value). If Width=32 and this is the LOW address, it's a single-register write to half a 32-bit tag — pass through raw + warn (the design's partial-overlap policy). Ifaddressis the HIGH register of a 32-bit pair, same partial-pass-through + warn. The PDU length is unchanged. - FC16:
TryGetForRange(start, qty); for each hit, re-encode the relevant register-pair-or-singleton. Partial-overlap warnings emitted per offending slot. - All other FCs: no-op.
- FC06: read
ResponseToClient: inspectpdu[0]:- FC03 / FC04:
TryGetForRange(echoedStart, byteCount/2). The start address isn't in the response (Modbus FC03 response =[fc, byteCount, ...data]), so the rewriter needs the matching request — see Task 4. - All other FCs: no-op.
- FC03 / FC04:
- Exceptions from
BcdCodec.Decode*are caught and turned intombproxy.rewrite.invalid_bcdwarnings; the byte is passed through unchanged.
- Request → response correlation. The rewriter on a response needs the original request's start-address and quantity. Since the proxy is 1:1 per-client (no multiplexing),
PlcConnectionPairkeeps the last-issued request's(fc, address, quantity)in a per-pair slot. When the response arrives, the rewriter is invoked with that slot's contents as part ofPerPlcContext. (We do NOT support pipelined multi-PDU requests on one socket in this phase; if a client tries, the slot is overwritten and the second response could mis-decode. Document the limitation; phase 08 may revisit if real clients pipeline.) RewriterLogEvents.cs—[LoggerMessage]source-generated definitions:mbproxy.rewrite.partial_bcd— Warning, params: PlcName, Address, ClientStart, ClientQty.mbproxy.rewrite.invalid_bcd— Warning, params: PlcName, Address, RawValue, Direction.mbproxy.exception.passthrough— Information, params: PlcName, Fc, ExceptionCode. (Moved here from a phase-03 TODO.)
Public surface declared in this phase
namespace Mbproxy.Proxy;
internal sealed class BcdPduPipeline : IPduPipeline { /* full impl */ }
internal sealed class PerPlcContext { public string PlcName; public BcdTagMap TagMap; public ProxyCounters Counters; public ILogger Logger; }
internal sealed class ProxyCounters {
public void IncrementPdusForwarded();
public void IncrementFcCount(byte fc);
public void AddRewrittenSlots(int n);
public void IncrementPartialBcd();
public void IncrementInvalidBcd();
public void IncrementBackendException(byte code);
public void AddBytes(long up, long down);
public CounterSnapshot Snapshot();
}
public sealed record CounterSnapshot(/* mirrors design.md per-PLC status fields */);
Nothing else becomes public.
Tests required
Unit (Category = Unit)
BcdPduPipelineTests (≥ 20 tests). Each test builds a synthetic PDU byte array + a PerPlcContext with a hand-rolled BcdTagMap, calls pipeline.Process, and asserts the resulting bytes.
Coverage matrix:
| FC | Tag scenario | Expected | Counter delta |
|---|---|---|---|
| 03 response | single 16-bit BCD at the read address | bytes replaced with binary-encoded value | RewrittenSlots += 1 |
| 03 response | full 32-bit BCD pair within read range | both register-bytes replaced with binary-encoded 32-bit value | RewrittenSlots += 2 |
| 03 response | partial 32-bit (low only, qty=1 at low addr) | bytes unchanged | PartialBcd += 1 |
| 03 response | partial 32-bit (high only, qty=1 at high addr) | bytes unchanged | PartialBcd += 1 |
| 03 response | mixed: 16-bit + non-BCD in same read | only the 16-bit slot rewritten | RewrittenSlots += 1 |
| 03 response | bad nibble (0x12A4) at a 16-bit BCD slot | bytes unchanged | InvalidBcd += 1 |
| 04 response | 16-bit BCD at the read address | same as FC03 | RewrittenSlots += 1 |
| 06 request | write to 16-bit BCD address | binary integer in payload → BCD nibbles | RewrittenSlots += 1 |
| 06 request | write to the LOW addr of a 32-bit pair (qty=1) | bytes unchanged (partial) | PartialBcd += 1 |
| 06 request | write to the HIGH addr of a 32-bit pair | bytes unchanged (partial) | PartialBcd += 1 |
| 06 request | write value outside [0,9999] for 16-bit |
mbproxy.rewrite.invalid_bcd Warning; bytes unchanged |
InvalidBcd += 1 |
| 16 request | write multi covering one 16-bit BCD + 3 non-BCD | only the 16-bit slot re-encoded | RewrittenSlots += 1 |
| 16 request | write multi covering one full 32-bit pair | both registers re-encoded as the CDAB pair | RewrittenSlots += 2 |
| 16 request | write multi crossing into one half of a 32-bit pair | partial slot passed through; warn | PartialBcd += 1 |
| 01 / 02 / 05 / 15 | any | no-op | none |
| 03 exception response | exception 02 returned by PLC | bytes unchanged, no rewriting attempted | BackendExceptions[2] += 1, mbproxy.exception.passthrough logged |
Additional:
- Counter snapshot reflects increments exactly (no off-by-one).
- Empty
BcdTagMapproduces zero rewrites for any FC.
E2E (Category = E2E, [Collection(nameof(DL205SimulatorCollection))])
RewriterE2ETests (≥ 6 tests, all against the dl205.json simulator profile):
Read_HR1072_AsBcd_ReturnsDecoded_1234— configure the BCD tag at addr 1072 width 16; assert1234.Read_HR1072_AsRaw_WhenNotConfigured_Returns_0x1234— no BCD tags configured; assert raw4660. (Verifies the pipeline is opt-in per tag.)Write_HR200_AsBcd_StoresEncoded_0x9876— configure addr 200 width 16. Write decimal 9876 through proxy; read raw from sim, expect0x9876(39030).Read_HR1056_HR1057_AsBcd32_ReturnsDecoded_From_CDAB— seed an alternate profile (or write via proxy first if the default profile's float32 markers aren't suitable BCD32 fixtures). Verify the CDAB layout end-to-end.Partial_FC03_OnHighRegisterOf_32BitPair_PassesThroughRaw_AndLogsWarning— use the in-memory Serilog sink to verifymbproxy.rewrite.partial_bcdwas logged.MbapTxId_StillPreserved_AfterRewriting_20Consecutive— same as phase 03's test 5, but with BCD rewrite in the path. Proves rewriting doesn't tamper with the MBAP header.
Phase gate
- Zero-warnings build.
- All phase 00–03 tests still green (with the phase-03 placeholder test renamed/repurposed as described).
- All new unit tests green (≥ 16 in BcdPduPipelineTests + counter snapshot tests).
- All new e2e tests green when simulator is available.
- PDU rewriting NEVER changes the MBAP
lengthfield; verify in a unit test that re-encoded PDUs are exactly the same byte length as the originals. ProxyCountersis allocation-free per increment on the hot path. TheSnapshot()call may allocate (it's used only by the status page, off the hot path).- Log event names match
../design.md→ Logging table exactly (including the newmbproxy.rewrite.invalid_bcdevent added here — update design.md in this PR to add the row).
Out of scope
- Auto-recovery of failed listener binds (phase 05).
- Backend-connect retry pipeline (phase 05).
- Counter exposure via HTTP (phase 07).
- Hot-reload of the per-PLC
BcdTagMap(phase 06). - Pipelined / multi-PDU-in-flight on a single client socket. The proxy serialises by the design's 1:1 model; if a real client pipelines, document as a known limitation.
Notes for the subagent
- The Modbus FC03/04 response does NOT carry the start address — only the byte count and the register data. You must remember the last request's
(startAddress, quantity)perPlcConnectionPair. This is fine because the proxy is 1:1 and one client = one in-flight request at a time. - For FC16 requests, the wire format is
[fc, startHi, startLo, qtyHi, qtyLo, byteCount, ...data]. The PDU passed to the pipeline starts atfc. Compute slot offsets fromstartAddress + (offsetInData / 2). - Update
../design.md→ Logging events table to add the newmbproxy.rewrite.invalid_bcdevent. Do this in the same PR; the doc and the code stay in sync. - The
mbproxy.exception.passthroughevent was specified in design.md but not wired in phase 03. This phase wires it. If during phase 03 it was already wired by mistake, leave it and remove the TODO comment.