Files
wwtools/mbproxy/docs/plan/02-bcd-codec.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

9.4 KiB

Phase 02 — BCD codec

Pure logic for encoding integers as DirectLOGIC BCD nibbles and decoding nibbles back. No I/O, no network, no Modbus framing. The codec exposed by this phase is what phase 04 plugs into the proxy.

Depends on: Phase 00 (csproj + options POCOs). Parallel-safe with: Phase 01, Phase 03. (All work lives under src/Mbproxy/Bcd/ and tests/Mbproxy.Tests/Bcd/ — disjoint from sim harness and proxy plumbing.)

Goal

A tiny, allocation-free codec library that:

  • Encodes a non-negative int (capped at the width's range) to either one 16-bit raw register value or a (low, high) register pair for 32-bit BCD per the design's CDAB digit-layout rule.
  • Decodes one or two raw register values back to an int.
  • Resolves Global + per-PLC Add - per-PLC Remove into an immutable per-PLC BcdTagMap that the rewriter looks up by Modbus address in O(1).

The codec is the single source of BCD-encoding correctness in the system. Phase 04 must not reimplement any nibble math.

Outputs

src/Mbproxy/Bcd/BcdCodec.cs           # static class: Encode16, Decode16, Encode32, Decode32
src/Mbproxy/Bcd/BcdTag.cs              # the public record (mirrors design.md exactly)
src/Mbproxy/Bcd/BcdTagMap.cs           # immutable, address-keyed lookup; describes per-PLC resolved tags
src/Mbproxy/Bcd/BcdTagMapBuilder.cs    # resolves global + Add - Remove into a map; runs validation
src/Mbproxy/Bcd/BcdValidationError.cs  # enum + ValidationResult record

tests/Mbproxy.Tests/Bcd/BcdCodecTests.cs
tests/Mbproxy.Tests/Bcd/BcdTagMapBuilderTests.cs

No other files. The proxy plumbing layer doesn't exist yet and isn't touched.

Tasks

  1. BcdTag.cspublic sealed record BcdTag(ushort Address, byte Width) with a static factory Create(ushort, byte) that throws on Width != 16 && Width != 32. This record is the type phases 04 / 06 / 07 will use.
  2. BcdCodec.csinternal static class with four pure methods. Internal because the proxy is the only consumer; nothing else in the assembly should call these.
    • static ushort Encode16(int value) — value in [0, 9999]; produces the 16-bit BCD register, e.g. 1234 → 0x1234. Throws ArgumentOutOfRangeException if value is out of range.
    • static int Decode16(ushort raw) — inverse. If any nibble is >= 0xA, return a int.MinValue sentinel? No — throw FormatException with the raw value in the message. The rewriter catches this and surfaces a mbproxy.rewrite.invalid_bcd event (event name added in phase 04).
    • static (ushort low, ushort high) Encode32(int value) — value in [0, 99_999_999]; produces the CDAB pair, where low = low 4 BCD digits (least-significant) and high = high 4 BCD digits (most-significant). Decoded decimal = high * 10000 + low_as_bcd_decoded. Throws if out of range.
    • static int Decode32(ushort low, ushort high) — inverse. Throws FormatException if either word has a bad nibble.
  3. BcdTagMap.cspublic sealed class BcdTagMap wrapping a frozen address-keyed dictionary. Methods:
    • static BcdTagMap Empty { get; }
    • bool TryGet(ushort address, out BcdTag tag) — O(1) lookup.
    • bool TryGetForRange(ushort startAddress, ushort qty, out IEnumerable<(int offset, BcdTag tag)> hits) — returns every BCD tag whose register footprint intersects [startAddress, startAddress+qty). Offsets are relative to startAddress. Used by the rewriter to know which slots in a multi-register PDU to touch.
    • int Count { get; }, IEnumerable<BcdTag> All { get; } — for telemetry / status page.
  4. BcdTagMapBuilder.cs — given BcdTagListOptions Global and PlcBcdOverrides? perPlc, produce a (BcdTagMap, ValidationResult). Validation rules from design.md:
    • Reject duplicate addresses within the resolved list (Add+Global after Remove).
    • Reject 32-bit entries whose high register (Address+1) collides with any other entry's address (16-bit or 32-bit).
    • Warn on Remove entries that don't match any address in Global (this is not a failure; the warning rides on ValidationResult.Warnings).
    • Reject Width values other than 16/32 (defensive; phase 00's IValidateOptions should already have caught this, but the builder is the last line of defence).
  5. BcdValidationError.cspublic enum BcdValidationError { DuplicateAddress, OverlappingHighRegister, InvalidWidth }. public sealed record ValidationResult(BcdTagMap Map, IReadOnlyList<BcdError> Errors, IReadOnlyList<BcdWarning> Warnings). Errors fail the build; warnings ride along.

Public surface declared in this phase

namespace Mbproxy.Bcd;

public sealed record BcdTag(ushort Address, byte Width) {
    public static BcdTag Create(ushort address, byte width);
    public bool IsThirtyTwoBit => Width == 32;
    public ushort HighRegister => (ushort)(Address + 1);   // throws if Width != 32
}

public sealed class BcdTagMap {
    public static BcdTagMap Empty { get; }
    public int Count { get; }
    public IEnumerable<BcdTag> All { get; }
    public bool TryGet(ushort address, out BcdTag tag);
    public bool TryGetForRange(ushort startAddress, ushort qty, out IReadOnlyList<RangeHit> hits);
}

public readonly record struct RangeHit(int OffsetWords, BcdTag Tag);

public static class BcdTagMapBuilder {
    public static ValidationResult Build(BcdTagListOptions global, PlcBcdOverrides? perPlc);
}

public sealed record ValidationResult(
    BcdTagMap Map,
    IReadOnlyList<BcdError> Errors,
    IReadOnlyList<BcdWarning> Warnings);

public sealed record BcdError(BcdValidationError Kind, string Message, ushort? Address);
public sealed record BcdWarning(string Message, ushort? Address);
public enum BcdValidationError { DuplicateAddress, OverlappingHighRegister, InvalidWidth }
namespace Mbproxy.Bcd;
internal static class BcdCodec {
    public static ushort Encode16(int value);
    public static int Decode16(ushort raw);
    public static (ushort low, ushort high) Encode32(int value);
    public static int Decode32(ushort low, ushort high);
}

Tests required

Unit (Category = Unit)

BcdCodecTests (≥ 16 tests):

  1. Encode16_1234_Returns_0x1234
  2. Encode16_0_Returns_0x0000
  3. Encode16_9999_Returns_0x9999
  4. Encode16_10000_Throws_OutOfRange
  5. Encode16_Negative_Throws_OutOfRange
  6. Decode16_0x1234_Returns_1234
  7. Decode16_0x0000_Returns_0
  8. Decode16_0x9999_Returns_9999
  9. Decode16_0x123A_Throws_Format — bad nibble A.
  10. Encode32_12345678_Returns_LowHigh_5678_1234 — verify low = 0x5678, high = 0x1234.
  11. Encode32_0_Returns_LowHigh_0_0
  12. Encode32_99999999_Returns_LowHigh_9999_9999
  13. Encode32_100000000_Throws_OutOfRange
  14. Decode32_LowHigh_5678_1234_Returns_12345678
  15. Decode32_BadNibble_InLow_Throws
  16. Decode32_BadNibble_InHigh_Throws
  17. RoundTrip16_AllValuesUnder10000[Theory] with [InlineData] for boundary values; for the dense check use [Theory] [MemberData] enumerating every 100th value. The codec must be Decode16(Encode16(v)) == v.

BcdTagMapBuilderTests (≥ 10 tests):

  1. Build_EmptyGlobal_EmptyOverride_ReturnsEmptyMap
  2. Build_GlobalOnly_PopulatesMap
  3. Build_PerPlcAdd_AppendsToGlobal
  4. Build_PerPlcRemove_DropsFromGlobal
  5. Build_AddOverrideSameAddressAsGlobal_AddWidthWins
  6. Build_DuplicateAddressInGlobal_ReturnsDuplicateAddressError
  7. Build_32BitHighRegOverlaps16BitGlobal_ReturnsOverlappingHighRegisterError
  8. Build_Remove_OfNonExistentAddress_ReturnsWarning_NotError
  9. Build_InvalidWidth_ReturnsInvalidWidthError
  10. Map_TryGetForRange_ReturnsAllHits_InOrder — covers full overlap, partial overlap (low only, high only), and no overlap.

E2E (Category = E2E)

None. The codec is pure logic.

Phase gate

  • Zero-warnings build.
  • dotnet test --filter Category=Unit — all green, ≥ 26 new tests.
  • BcdCodec is internal; nothing outside Mbproxy.Bcd calls it directly.
  • BcdTagMap has zero allocations on TryGet and on the hot TryGetForRange path (verify via a microbench note in the test file's docstring; no benchmark project added).
  • ../design.md → "BCD tag shape" matches the public record exactly; if the spec drifted during implementation, update design.md in this PR.

Out of scope

  • Signed BCD. Design explicitly excludes it.
  • Half-byte / "BCD with sign nibble" variants used by some DL-family math instructions. Not in the design's tag shape.
  • The actual PDU-byte-level rewriting (FC parsing, MBAP framing). That's phase 04.
  • Telemetry counters. The codec exposes nothing to counters; phase 04 instruments the rewrite pipeline that USES the codec.

Notes for the subagent

  • The DirectLOGIC CDAB digit layout is the most-likely-to-confuse part of this phase. Re-read ../design.md → "BCD tag shape" and ../../DL260/dl205.md → "Word Order" before implementing Encode32/Decode32. The seeded marker in dl205.json for the float32 case (HR[1056]=0x0000, HR[1057]=0x3FC0 for IEEE 1.5) confirms low-word-first; the BCD-32 case is the same word order with BCD nibble semantics inside each word.
  • BcdTagMapBuilder is single-shot — given inputs, produce a map. There is NO IObservable<BcdTagMap> here. Phase 06 owns reload-driven rebuilds and just calls Build again.
  • TryGetForRange is on the hot path for FC03/04 responses. Implementation should pre-bucket BCD tags by 256-register window if it makes the lookup faster, but only if a microbench shows a real win. Don't preoptimise.