Files
wwtools/mbproxy/docs/plan/04-rewriter-integration.md
T
Joseph Doherty 56eee3c563 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>
2026-05-14 01:49:35 -04:00

12 KiB
Raw Blame History

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_bcd warning, 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 — replace PduContext (placeholder from phase 03) with PerPlcContext. 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 one PerPlcContext per configured PLC at startup (calls BcdTagMapBuilder.Build and wraps the resulting map + a fresh ProxyCounters + a per-PLC logger). Stash the contexts in a Dictionary<string, PerPlcContext> keyed by PLC name.
  • src/Mbproxy/Program.cs — register BcdPduPipeline as the IPduPipeline singleton; remove the NoopPduPipeline registration. The phase 03 NoopPduPipeline.cs file stays (it's useful in tests as a baseline) but is no longer wired in production.
  • tests/Mbproxy.Tests/Proxy/ProxyForwardingTests.cs — update the test Forward_FC03_HR1072_Returns_RawBCD_0x1234 (which was a phase-03 baseline) to a new test Forward_FC03_HR1072_Returns_Decoded_1234 that asserts 1234. The original raw-passthrough behaviour is preserved by configuring a PLC with NO BCD tags.

Tasks

  1. ProxyCounters.csinternal sealed class holding long fields accessed via Interlocked.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.
  2. PerPlcContext.csinternal sealed class holding string PlcName, BcdTagMap TagMap, ProxyCounters Counters, ILogger Logger. Constructed once per PLC at startup; lifetime = lifetime of the listener.
  3. BcdPduPipeline.cs — implements IPduPipeline. Behaviour per direction:
    • RequestToBackend: inspect the PDU's function code byte (pdu[0]):
      • FC06: read (address, value) from pdu[1..]. If TagMap.TryGet(address) and Width=16, replace value bytes with BcdCodec.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). If address is 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.
    • ResponseToClient: inspect pdu[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.
    • Exceptions from BcdCodec.Decode* are caught and turned into mbproxy.rewrite.invalid_bcd warnings; the byte is passed through unchanged.
  4. 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), PlcConnectionPair keeps 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 of PerPlcContext. (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.)
  5. 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 BcdTagMap produces zero rewrites for any FC.

E2E (Category = E2E, [Collection(nameof(DL205SimulatorCollection))])

RewriterE2ETests (≥ 6 tests, all against the dl205.json simulator profile):

  1. Read_HR1072_AsBcd_ReturnsDecoded_1234 — configure the BCD tag at addr 1072 width 16; assert 1234.
  2. Read_HR1072_AsRaw_WhenNotConfigured_Returns_0x1234 — no BCD tags configured; assert raw 4660. (Verifies the pipeline is opt-in per tag.)
  3. Write_HR200_AsBcd_StoresEncoded_0x9876 — configure addr 200 width 16. Write decimal 9876 through proxy; read raw from sim, expect 0x9876 (39030).
  4. 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.
  5. Partial_FC03_OnHighRegisterOf_32BitPair_PassesThroughRaw_AndLogsWarning — use the in-memory Serilog sink to verify mbproxy.rewrite.partial_bcd was logged.
  6. 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 0003 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 length field; verify in a unit test that re-encoded PDUs are exactly the same byte length as the originals.
  • ProxyCounters is allocation-free per increment on the hot path. The Snapshot() 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 new mbproxy.rewrite.invalid_bcd event 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) per PlcConnectionPair. 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 at fc. Compute slot offsets from startAddress + (offsetInData / 2).
  • Update ../design.md → Logging events table to add the new mbproxy.rewrite.invalid_bcd event. Do this in the same PR; the doc and the code stay in sync.
  • The mbproxy.exception.passthrough event 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.