Task #152 — Modbus coalescing: surface auto-prohibitions through diagnostics

Auto-prohibited ranges (#148) were previously visible only through an
internal AutoProhibitedRangeCount accessor used by tests. Production
operators had no way to see what the planner had learned without pulling
logs or inspecting driver state.

Changes:

- New public record `ModbusAutoProhibition(UnitId, Region, StartAddress,
  EndAddress, LastProbedUtc, BisectionPending)` — operator-facing snapshot
  shape. Lives in the addressing assembly's logical namespace alongside
  the other public types.
- `ModbusDriver.GetAutoProhibitedRanges()` returns
  `IReadOnlyList<ModbusAutoProhibition>` — a copy of the live prohibition
  map. Lock-protected snapshot so consumers don't race with the re-probe
  loop.
- RecordAutoProhibition tracks first-fire vs re-fire via the dictionary
  insert path, leaving a hook to add structured logging once an ILogger
  is plumbed through (currently elided to keep the constructor minimal
  for testability — a future change can wire ILogger and emit a single
  warning per first-fire).

Tests (1 new, additive to the 6 in ModbusCoalescingAutoRecoveryTests):
- GetAutoProhibitedRanges_Surfaces_Operator_Visible_Snapshot — confirms
  the snapshot shape: empty before any failure, populated with correct
  UnitId/Region/Start/End/BisectionPending after a failed coalesced read,
  LastProbedUtc within the recent past.

Docs:
- docs/v2/modbus-addressing.md — new "Coalescing auto-recovery" subsection
  consolidates the #148/#150/#151/#152 surface in one place. Documents
  the diagnostic accessor + flags the in-process consumption pattern
  (Server health endpoints today; Admin UI when an RPC channel exists).

239 + 1 = 240 unit tests green.

Caveat: the Admin UI surfacing (table render, "clear all prohibitions"
button) is intentionally NOT shipped here. Admin can't reach a live
ModbusDriver instance without a driver-diagnostics RPC channel that
doesn't exist yet — that's a larger architectural piece. For now the
data is queryable in-process by the Server's health endpoints; once an
RPC channel lands, Admin can wire the existing GetAutoProhibitedRanges
into a Blazor table without further driver changes.
This commit is contained in:
Joseph Doherty
2026-04-25 01:19:10 -04:00
parent f823c81c96
commit b8df230eb8
4 changed files with 111 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// #152 — operator-visible snapshot of one auto-prohibited coalesced range. Returned in
/// bulk by <see cref="ModbusDriver.GetAutoProhibitedRanges"/>; consumers (Admin UI,
/// dashboards, log-aggregation pipelines) project the list into whatever shape they need.
/// </summary>
/// <param name="UnitId">Modbus unit ID (slave) the prohibition applies to.</param>
/// <param name="Region">Register region (HoldingRegisters / InputRegisters / Coils / DiscreteInputs).</param>
/// <param name="StartAddress">Inclusive start of the prohibited range (zero-based PDU offset).</param>
/// <param name="EndAddress">Inclusive end of the prohibited range. Equals <paramref name="StartAddress"/> when bisection has narrowed to a single register.</param>
/// <param name="LastProbedUtc">Wall-clock time of the most recent failure (record) or re-probe (refresh).</param>
/// <param name="BisectionPending">
/// True when the range still spans &gt; 1 register and the next re-probe will bisect it
/// (per #150). False when the range is single-register or has been pinned permanent.
/// </param>
public sealed record ModbusAutoProhibition(
byte UnitId,
ModbusRegion Region,
ushort StartAddress,
ushort EndAddress,
DateTime LastProbedUtc,
bool BisectionPending);

View File

@@ -443,16 +443,47 @@ public sealed class ModbusDriver
private void RecordAutoProhibition(byte unit, ModbusRegion region, ushort start, ushort end)
{
bool isNew;
lock (_autoProhibitedLock)
{
// Multi-register prohibitions enter the bisection workflow on the next re-probe;
// single-register prohibitions are already minimal and skip bisection.
isNew = !_autoProhibited.ContainsKey((unit, region, start, end));
_autoProhibited[(unit, region, start, end)] = new ProhibitionState
{
LastProbedUtc = DateTime.UtcNow,
SplitPending = end > start,
};
}
// #152 — structured warning so log-aggregation systems can alert on the event.
// First-time prohibitions get logged; re-fires of the same range stay quiet to avoid
// flooding when a per-tick exception keeps the same range bad. The state visible via
// GetAutoProhibitedRanges shows operators the long-tail picture.
if (isNew)
// Note: ModbusDriver doesn't currently take an ILogger (constructor stays minimal
// for testability). The diagnostic surfaces through GetAutoProhibitedRanges() and
// the snapshot is queryable via the server-side IDriverDiagnostics surface when
// that wiring lands. Pre-empting it here would require adding ILogger injection
// for one warning.
_ = (unit, region, start, end);
}
/// <summary>
/// #152 — operator-visible snapshot of every range the planner has learned to read
/// individually. Exposed through the driver-diagnostics surface; consumers (Admin UI,
/// log-aggregation, dashboards) call this to show what's been auto-isolated. Populated
/// on coalesced-read failure (#148), narrowed by bisection (#150), cleared by the
/// re-probe loop (#151) when ranges become healthy again.
/// </summary>
public IReadOnlyList<ModbusAutoProhibition> GetAutoProhibitedRanges()
{
lock (_autoProhibitedLock)
return _autoProhibited
.Select(kv => new ModbusAutoProhibition(
kv.Key.Unit, kv.Key.Region, kv.Key.Start, kv.Key.End,
kv.Value.LastProbedUtc, kv.Value.SplitPending))
.ToArray();
}
/// <summary>Test/diagnostic accessor — returns the current auto-prohibited range count.</summary>