56eee3c563
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>
9.4 KiB
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 Removeinto an immutable per-PLCBcdTagMapthat 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
BcdTag.cs—public sealed record BcdTag(ushort Address, byte Width)with a static factoryCreate(ushort, byte)that throws onWidth != 16 && Width != 32. This record is the type phases 04 / 06 / 07 will use.BcdCodec.cs—internal static classwith 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. ThrowsArgumentOutOfRangeExceptionif value is out of range.static int Decode16(ushort raw)— inverse. If any nibble is>= 0xA, return aint.MinValuesentinel? No — throwFormatExceptionwith the raw value in the message. The rewriter catches this and surfaces ambproxy.rewrite.invalid_bcdevent (event name added in phase 04).static (ushort low, ushort high) Encode32(int value)— value in[0, 99_999_999]; produces the CDAB pair, wherelow= low 4 BCD digits (least-significant) andhigh= 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. ThrowsFormatExceptionif either word has a bad nibble.
BcdTagMap.cs—public sealed class BcdTagMapwrapping 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 tostartAddress. 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.
BcdTagMapBuilder.cs— givenBcdTagListOptions GlobalandPlcBcdOverrides? 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
Removeentries that don't match any address in Global (this is not a failure; the warning rides onValidationResult.Warnings). - Reject
Widthvalues other than 16/32 (defensive; phase 00'sIValidateOptionsshould already have caught this, but the builder is the last line of defence).
BcdValidationError.cs—public 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):
Encode16_1234_Returns_0x1234Encode16_0_Returns_0x0000Encode16_9999_Returns_0x9999Encode16_10000_Throws_OutOfRangeEncode16_Negative_Throws_OutOfRangeDecode16_0x1234_Returns_1234Decode16_0x0000_Returns_0Decode16_0x9999_Returns_9999Decode16_0x123A_Throws_Format— bad nibbleA.Encode32_12345678_Returns_LowHigh_5678_1234— verifylow = 0x5678,high = 0x1234.Encode32_0_Returns_LowHigh_0_0Encode32_99999999_Returns_LowHigh_9999_9999Encode32_100000000_Throws_OutOfRangeDecode32_LowHigh_5678_1234_Returns_12345678Decode32_BadNibble_InLow_ThrowsDecode32_BadNibble_InHigh_ThrowsRoundTrip16_AllValuesUnder10000—[Theory]with[InlineData]for boundary values; for the dense check use[Theory] [MemberData]enumerating every 100th value. The codec must beDecode16(Encode16(v)) == v.
BcdTagMapBuilderTests (≥ 10 tests):
Build_EmptyGlobal_EmptyOverride_ReturnsEmptyMapBuild_GlobalOnly_PopulatesMapBuild_PerPlcAdd_AppendsToGlobalBuild_PerPlcRemove_DropsFromGlobalBuild_AddOverrideSameAddressAsGlobal_AddWidthWinsBuild_DuplicateAddressInGlobal_ReturnsDuplicateAddressErrorBuild_32BitHighRegOverlaps16BitGlobal_ReturnsOverlappingHighRegisterErrorBuild_Remove_OfNonExistentAddress_ReturnsWarning_NotErrorBuild_InvalidWidth_ReturnsInvalidWidthErrorMap_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.BcdCodecisinternal; nothing outsideMbproxy.Bcdcalls it directly.BcdTagMaphas zero allocations onTryGetand on the hotTryGetForRangepath (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 implementingEncode32/Decode32. The seeded marker indl205.jsonfor the float32 case (HR[1056]=0x0000, HR[1057]=0x3FC0for IEEE 1.5) confirms low-word-first; the BCD-32 case is the same word order with BCD nibble semantics inside each word. BcdTagMapBuilderis single-shot — given inputs, produce a map. There is NOIObservable<BcdTagMap>here. Phase 06 owns reload-driven rebuilds and just callsBuildagain.TryGetForRangeis 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.