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>
112 lines
4.8 KiB
C#
112 lines
4.8 KiB
C#
namespace Mbproxy.Bcd;
|
||
|
||
/// <summary>
|
||
/// Pure, allocation-free codec for DirectLOGIC BCD register encoding/decoding.
|
||
///
|
||
/// 16-bit BCD: one register holds 4 BCD digits (0–9999).
|
||
/// Wire value 0x1234 decodes to decimal 1234.
|
||
///
|
||
/// 32-bit BCD (CDAB word order, low-word-first):
|
||
/// Register at Address = low 4 BCD digits (least-significant).
|
||
/// Register at Address+1 = high 4 BCD digits (most-significant).
|
||
/// Decoded decimal = Decode16(high) * 10_000 + Decode16(low).
|
||
/// Example: 12_345_678 → low=0x5678, high=0x1234.
|
||
///
|
||
/// Bad-nibble policy: Decode16/Decode32 throw <see cref="FormatException"/>
|
||
/// (not a sentinel). The Phase 04 rewrite pipeline catches and surfaces the
|
||
/// exception as an mbproxy.rewrite.invalid_bcd warning event.
|
||
/// </summary>
|
||
internal static class BcdCodec
|
||
{
|
||
private const int Max16 = 9_999;
|
||
private const int Max32 = 99_999_999;
|
||
|
||
// ── Encode ──────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Encodes a non-negative integer in [0, 9999] to a 16-bit BCD register.
|
||
/// E.g. 1234 → 0x1234.
|
||
/// </summary>
|
||
/// <exception cref="ArgumentOutOfRangeException">value < 0 or value > 9999.</exception>
|
||
public static ushort Encode16(int value)
|
||
{
|
||
if ((uint)value > Max16)
|
||
throw new ArgumentOutOfRangeException(nameof(value),
|
||
value, $"BCD-16 value must be in [0, {Max16}]; got {value}.");
|
||
|
||
// Pack four decimal digits into four BCD nibbles.
|
||
int d3 = value / 1000;
|
||
int d2 = (value / 100) % 10;
|
||
int d1 = (value / 10) % 10;
|
||
int d0 = value % 10;
|
||
return (ushort)((d3 << 12) | (d2 << 8) | (d1 << 4) | d0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Encodes a non-negative integer in [0, 99_999_999] to a CDAB BCD register pair.
|
||
/// Returns (low, high) where low holds the 4 least-significant BCD digits and
|
||
/// high holds the 4 most-significant BCD digits.
|
||
/// E.g. 12_345_678 → (low: 0x5678, high: 0x1234).
|
||
/// </summary>
|
||
/// <exception cref="ArgumentOutOfRangeException">value < 0 or value > 99_999_999.</exception>
|
||
public static (ushort low, ushort high) Encode32(int value)
|
||
{
|
||
if ((uint)value > Max32)
|
||
throw new ArgumentOutOfRangeException(nameof(value),
|
||
value, $"BCD-32 value must be in [0, {Max32}]; got {value}.");
|
||
|
||
int lo = value % 10_000; // low 4 decimal digits
|
||
int hi = value / 10_000; // high 4 decimal digits
|
||
return (Encode16(lo), Encode16(hi));
|
||
}
|
||
|
||
// ── Decode ──────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Decodes a 16-bit BCD register to a non-negative integer.
|
||
/// E.g. 0x1234 → 1234.
|
||
/// </summary>
|
||
/// <exception cref="FormatException">Any nibble is >= 0xA (not a valid BCD digit).</exception>
|
||
public static int Decode16(ushort raw)
|
||
{
|
||
// Validate all four nibbles first (fail fast with the raw value in the message).
|
||
if (HasBadNibble(raw))
|
||
throw new FormatException(
|
||
$"Register value 0x{raw:X4} is not valid BCD: one or more nibbles are >= 0xA.");
|
||
|
||
int d3 = (raw >> 12) & 0xF;
|
||
int d2 = (raw >> 8) & 0xF;
|
||
int d1 = (raw >> 4) & 0xF;
|
||
int d0 = raw & 0xF;
|
||
return d3 * 1000 + d2 * 100 + d1 * 10 + d0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Decodes a CDAB BCD register pair to a non-negative integer.
|
||
/// <paramref name="low"/> = low 4 BCD digits; <paramref name="high"/> = high 4 BCD digits.
|
||
/// E.g. (low: 0x5678, high: 0x1234) → 12_345_678.
|
||
/// </summary>
|
||
/// <exception cref="FormatException">Either word has a bad nibble.</exception>
|
||
public static int Decode32(ushort low, ushort high)
|
||
{
|
||
// Decode high first: if it throws, we skip decoding low unnecessarily.
|
||
// But the spec says "throws once with the raw value" per word, so we decode
|
||
// in natural order. Decode16 throws on the first bad word it encounters.
|
||
int hiVal = Decode16(high);
|
||
int loVal = Decode16(low);
|
||
return hiVal * 10_000 + loVal;
|
||
}
|
||
|
||
// ── Private helpers ─────────────────────────────────────────────────────
|
||
|
||
/// <summary>Returns true if any nibble in <paramref name="raw"/> is >= 0xA.</summary>
|
||
private static bool HasBadNibble(ushort raw)
|
||
{
|
||
// Check each nibble independently.
|
||
return ((raw >> 12) & 0xF) >= 0xA
|
||
|| ((raw >> 8) & 0xF) >= 0xA
|
||
|| ((raw >> 4) & 0xF) >= 0xA
|
||
|| (raw & 0xF) >= 0xA;
|
||
}
|
||
}
|