Files
wwtools/mbproxy/src/Mbproxy/Bcd/BcdCodec.cs
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

112 lines
4.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace Mbproxy.Bcd;
/// <summary>
/// Pure, allocation-free codec for DirectLOGIC BCD register encoding/decoding.
///
/// 16-bit BCD: one register holds 4 BCD digits (09999).
/// 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 &lt; 0 or value &gt; 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 &lt; 0 or value &gt; 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 &gt;= 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;
}
}