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>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace Mbproxy.Bcd;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable description of a single BCD-encoded V-memory tag as seen on the Modbus wire.
|
||||
/// Width is 16 (one register) or 32 (two registers, CDAB low-word-first).
|
||||
/// </summary>
|
||||
public sealed record BcdTag(ushort Address, byte Width)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="BcdTag"/> and validates that Width is 16 or 32.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Width is not 16 or 32.</exception>
|
||||
public static BcdTag Create(ushort address, byte width)
|
||||
{
|
||||
if (width != 16 && width != 32)
|
||||
throw new ArgumentException(
|
||||
$"BCD tag Width must be 16 or 32; got {width} at address {address}.",
|
||||
nameof(width));
|
||||
|
||||
return new BcdTag(address, width);
|
||||
}
|
||||
|
||||
/// <summary>True when this tag occupies two registers (32-bit BCD).</summary>
|
||||
public bool IsThirtyTwoBit => Width == 32;
|
||||
|
||||
/// <summary>
|
||||
/// The address of the high-word register for a 32-bit tag (Address + 1).
|
||||
/// Only valid when <see cref="IsThirtyTwoBit"/> is true.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Tag is 16-bit.</exception>
|
||||
public ushort HighRegister =>
|
||||
IsThirtyTwoBit
|
||||
? (ushort)(Address + 1)
|
||||
: throw new InvalidOperationException(
|
||||
$"HighRegister is only defined for 32-bit BCD tags (Address {Address} is {Width}-bit).");
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace Mbproxy.Bcd;
|
||||
|
||||
/// <summary>
|
||||
/// A hit returned by <see cref="BcdTagMap.TryGetForRange"/>.
|
||||
/// <see cref="OffsetWords"/> is the zero-based word offset of the tag's low register
|
||||
/// within the requested read range [startAddress, startAddress+qty).
|
||||
/// </summary>
|
||||
public readonly record struct RangeHit(int OffsetWords, BcdTag Tag);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable, address-keyed lookup of BCD tags resolved for a single PLC.
|
||||
/// All hot-path methods are allocation-free on the no-hit path.
|
||||
/// </summary>
|
||||
public sealed class BcdTagMap
|
||||
{
|
||||
// ── Empty singleton ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>An empty map with no tags. Returned when no tags are configured.</summary>
|
||||
public static BcdTagMap Empty { get; } = new(FrozenDictionary<ushort, BcdTag>.Empty);
|
||||
|
||||
// Reusable empty list for the no-hit path in TryGetForRange — zero allocation.
|
||||
private static readonly IReadOnlyList<RangeHit> s_emptyHits =
|
||||
Array.Empty<RangeHit>();
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
|
||||
// FrozenDictionary gives O(1) lookup with minimal overhead after construction.
|
||||
private readonly FrozenDictionary<ushort, BcdTag> _map;
|
||||
|
||||
internal BcdTagMap(FrozenDictionary<ushort, BcdTag> map) => _map = map;
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Number of BCD tags in this map.</summary>
|
||||
public int Count => _map.Count;
|
||||
|
||||
/// <summary>All tags in the map (for telemetry / status page).</summary>
|
||||
public IEnumerable<BcdTag> All => _map.Values;
|
||||
|
||||
/// <summary>
|
||||
/// O(1) point lookup by Modbus register address.
|
||||
/// Allocation-free regardless of hit or miss.
|
||||
/// </summary>
|
||||
public bool TryGet(ushort address, out BcdTag tag)
|
||||
=> _map.TryGetValue(address, out tag!);
|
||||
|
||||
/// <summary>
|
||||
/// Returns every BCD tag whose register footprint intersects
|
||||
/// [<paramref name="startAddress"/>, <paramref name="startAddress"/> + <paramref name="qty"/>).
|
||||
///
|
||||
/// A 16-bit tag at address A intersects when A is in [start, start+qty).
|
||||
/// A 32-bit tag at address A intersects when A or A+1 is in [start, start+qty)
|
||||
/// — i.e. when A < start+qty AND A+1 >= start.
|
||||
///
|
||||
/// <see cref="RangeHit.OffsetWords"/> is the zero-based word position of the tag's
|
||||
/// low register relative to <paramref name="startAddress"/> (may be negative for a
|
||||
/// 32-bit tag whose low word starts before the range, but whose high word is in range).
|
||||
///
|
||||
/// Hits are returned sorted ascending by <see cref="RangeHit.OffsetWords"/>.
|
||||
/// On the no-hit path this method does not allocate.
|
||||
/// </summary>
|
||||
public bool TryGetForRange(ushort startAddress, ushort qty,
|
||||
out IReadOnlyList<RangeHit> hits)
|
||||
{
|
||||
if (_map.Count == 0 || qty == 0)
|
||||
{
|
||||
hits = s_emptyHits;
|
||||
return false;
|
||||
}
|
||||
|
||||
int rangeEnd = startAddress + qty; // exclusive upper bound (int to avoid overflow)
|
||||
List<RangeHit>? result = null;
|
||||
|
||||
foreach (var kvp in _map)
|
||||
{
|
||||
var tag = kvp.Value;
|
||||
int addr = tag.Address;
|
||||
|
||||
bool intersects;
|
||||
if (tag.IsThirtyTwoBit)
|
||||
{
|
||||
// 32-bit tag occupies [addr, addr+2).
|
||||
// Intersects when addr < rangeEnd AND addr+2 > startAddress.
|
||||
intersects = addr < rangeEnd && (addr + 2) > startAddress;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 16-bit tag occupies [addr, addr+1).
|
||||
intersects = addr >= startAddress && addr < rangeEnd;
|
||||
}
|
||||
|
||||
if (intersects)
|
||||
{
|
||||
result ??= new List<RangeHit>(4);
|
||||
result.Add(new RangeHit(addr - startAddress, tag));
|
||||
}
|
||||
}
|
||||
|
||||
if (result is null || result.Count == 0)
|
||||
{
|
||||
hits = s_emptyHits;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sort ascending by offset so Phase 04 can iterate in wire order.
|
||||
result.Sort(static (a, b) => a.OffsetWords.CompareTo(b.OffsetWords));
|
||||
hits = result;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Collections.Frozen;
|
||||
using Mbproxy.Options;
|
||||
|
||||
namespace Mbproxy.Bcd;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an immutable <see cref="BcdTagMap"/> from global options and optional per-PLC overrides.
|
||||
///
|
||||
/// Resolution algorithm (per design.md):
|
||||
/// 1. Start with the global tag list.
|
||||
/// 2. Remove any address present in perPlc.Remove.
|
||||
/// 3. Merge in perPlc.Add entries — if an address exists in the working set the Add entry wins
|
||||
/// (this is how a per-PLC width override is expressed).
|
||||
///
|
||||
/// Validation:
|
||||
/// - Duplicate address in the resolved list → BcdError(DuplicateAddress).
|
||||
/// - 32-bit high register (Address+1) collides with any other entry → BcdError(OverlappingHighRegister).
|
||||
/// - Width not 16 or 32 → BcdError(InvalidWidth).
|
||||
/// - Remove address not found in global → BcdWarning (not an error).
|
||||
/// </summary>
|
||||
public static class BcdTagMapBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the effective BCD tag list for one PLC and validates it.
|
||||
/// </summary>
|
||||
/// <param name="global">The global BCD tag list from <c>appsettings.json</c>.</param>
|
||||
/// <param name="perPlc">Optional per-PLC overrides (Add + Remove). May be null.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="ValidationResult"/> whose <see cref="ValidationResult.Map"/> contains
|
||||
/// only the entries that passed validation. Callers should treat non-empty
|
||||
/// <see cref="ValidationResult.Errors"/> as a fatal configuration problem.
|
||||
/// </returns>
|
||||
public static ValidationResult Build(BcdTagListOptions global, PlcBcdOverrides? perPlc)
|
||||
{
|
||||
var errors = new List<BcdError>();
|
||||
var warnings = new List<BcdWarning>();
|
||||
|
||||
// ── Step 1: collect the working set keyed by address ─────────────────
|
||||
// Dictionary preserves last-write-wins semantics for the Add override.
|
||||
var working = new Dictionary<ushort, BcdTagOptions>(global.Global.Count);
|
||||
|
||||
foreach (var tag in global.Global)
|
||||
working[tag.Address] = tag;
|
||||
|
||||
// ── Step 2: apply Remove ─────────────────────────────────────────────
|
||||
if (perPlc?.Remove is { } removeList)
|
||||
{
|
||||
foreach (var addr in removeList)
|
||||
{
|
||||
if (!working.Remove(addr))
|
||||
warnings.Add(new BcdWarning(
|
||||
$"Remove entry for address {addr} does not match any global tag; " +
|
||||
"the entry is probably stale.", addr));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: apply Add (override wins) ────────────────────────────────
|
||||
if (perPlc?.Add is { } addList)
|
||||
{
|
||||
foreach (var tag in addList)
|
||||
working[tag.Address] = tag;
|
||||
}
|
||||
|
||||
// ── Step 4: validate the resolved list ───────────────────────────────
|
||||
// We build a validated-entries list; only clean entries go into the map.
|
||||
var validated = new Dictionary<ushort, BcdTag>(working.Count);
|
||||
var seenAddresses = new HashSet<ushort>(working.Count);
|
||||
|
||||
foreach (var (addr, opt) in working)
|
||||
{
|
||||
// Width check first (defensive — IValidateOptions should have caught this already).
|
||||
if (opt.Width != 16 && opt.Width != 32)
|
||||
{
|
||||
errors.Add(new BcdError(BcdValidationError.InvalidWidth,
|
||||
$"Address {addr}: Width {opt.Width} is not 16 or 32.", addr));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Duplicate address check.
|
||||
if (!seenAddresses.Add(addr))
|
||||
{
|
||||
errors.Add(new BcdError(BcdValidationError.DuplicateAddress,
|
||||
$"Address {addr} appears more than once in the resolved tag list.", addr));
|
||||
continue;
|
||||
}
|
||||
|
||||
validated[addr] = BcdTag.Create(addr, opt.Width);
|
||||
}
|
||||
|
||||
// High-register collision check (only meaningful for 32-bit entries).
|
||||
foreach (var tag in validated.Values)
|
||||
{
|
||||
if (!tag.IsThirtyTwoBit)
|
||||
continue;
|
||||
|
||||
ushort highReg = tag.HighRegister;
|
||||
if (validated.TryGetValue(highReg, out var collision))
|
||||
{
|
||||
errors.Add(new BcdError(BcdValidationError.OverlappingHighRegister,
|
||||
$"32-bit BCD tag at address {tag.Address} has its high register " +
|
||||
$"({highReg}) colliding with the entry at address {collision.Address}.",
|
||||
tag.Address));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 5: build the frozen map from entries that have no errors ─────
|
||||
// Entries implicated in an OverlappingHighRegister error are still included
|
||||
// in the map so that the caller can see all context; the error list tells them
|
||||
// the config is invalid and must be corrected before the service is safe to run.
|
||||
// (If callers want to exclude bad entries they should check Errors.Count > 0
|
||||
// and refuse to start the listener for that PLC.)
|
||||
var frozen = validated.ToFrozenDictionary();
|
||||
var map = frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;
|
||||
|
||||
return new ValidationResult(map, errors, warnings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Mbproxy.Bcd;
|
||||
|
||||
/// <summary>Discriminates the class of validation failure in a resolved BCD tag list.</summary>
|
||||
public enum BcdValidationError
|
||||
{
|
||||
/// <summary>Two or more entries share the same Modbus register address.</summary>
|
||||
DuplicateAddress,
|
||||
|
||||
/// <summary>
|
||||
/// A 32-bit entry's high register (Address+1) collides with another entry's address.
|
||||
/// </summary>
|
||||
OverlappingHighRegister,
|
||||
|
||||
/// <summary>An entry has a Width that is not 16 or 32.</summary>
|
||||
InvalidWidth,
|
||||
}
|
||||
|
||||
/// <summary>A hard validation failure that prevents the map from being used.</summary>
|
||||
public sealed record BcdError(BcdValidationError Kind, string Message, ushort? Address);
|
||||
|
||||
/// <summary>A non-fatal advisory that rides along with the map.</summary>
|
||||
public sealed record BcdWarning(string Message, ushort? Address);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a <see cref="BcdTagMapBuilder.Build"/> call.
|
||||
/// When <see cref="Errors"/> is non-empty the map is partial (only valid entries are included).
|
||||
/// Callers should treat any error as a fatal configuration problem at startup.
|
||||
/// </summary>
|
||||
public sealed record ValidationResult(
|
||||
BcdTagMap Map,
|
||||
IReadOnlyList<BcdError> Errors,
|
||||
IReadOnlyList<BcdWarning> Warnings);
|
||||
Reference in New Issue
Block a user