Task #148 — Modbus block-coalescing: auto-recover from protected register holes
Pre-#148 behaviour: a coalesced FC03/FC04 read that crossed a write-only or PLC-fault register marked every member tag Bad until the operator manually flagged the offending tag with CoalesceProhibited. Healthy tags around the hole stayed broken indefinitely. Post-#148: two-stage recovery, no operator intervention needed. 1. Same-scan fallback: when a coalesced read fails with a Modbus exception (IllegalDataAddress, SlaveDeviceFailure, etc.), the planner does NOT mark members handled. The per-tag fallback in the same scan reads each member individually — non-protected members surface Good values immediately, and only the actual protected register stays Bad. 2. Cross-scan prohibition: the failed range (Unit, Region, Start, End) is recorded in a per-driver `_autoProhibited` set. On subsequent scans the planner checks each candidate merge against the set and refuses to re-form any block that overlaps a known-bad range. Net effect: after one scan with a failure, the protected range goes "per-tag mode" indefinitely while ranges around it keep coalescing normally. Communication failures (timeouts, socket drops) are NOT auto-prohibited — they're transport-level, not structural. The same coalesced read can succeed once the transport recovers; recording it as "permanently bad" would defeat coalescing for the whole driver instance. Auto-prohibition state lives for the driver lifetime and clears on ReinitializeAsync (operator restart). A periodic re-probe is a follow-up if deployments need it without a restart. Implementation: - Added `_autoProhibited` HashSet<(byte, ModbusRegion, ushort, ushort)> + `_autoProhibitedLock` on ModbusDriver. - `RangeIsAutoProhibited(unit, region, start, end)` overlap check called from the planner when forming blocks. - `RecordAutoProhibition(...)` called from the catch (ModbusException) branch. - The catch (Exception) branch (non-Modbus failures) keeps the pre-#148 "mark all Bad in this scan, don't auto-prohibit" behaviour. - Internal `AutoProhibitedRangeCount` accessor for tests. Tests (3 new ModbusCoalescingAutoRecoveryTests): - First_Failure_Falls_Back_To_PerTag_Same_Scan — three tags around a protected register at 102: T100 + T104 surface Good values via the per-tag fallback in the SAME scan; T102 surfaces the exception. - Second_Scan_Skips_Coalesced_Read_Of_Prohibited_Range — confirms scan 2 doesn't re-attempt the failed merge (no FC03 with quantity > 1 at the prohibited start). - Tags_Outside_Prohibited_Range_Still_Coalesce — separate cluster at HR 200..202 keeps coalescing normally even after the 100..104 cluster is prohibited. 234/234 unit tests green. Follow-ups intentionally NOT shipped (smaller, independent changes): - Bisection-style range narrowing — currently the prohibition range is the full failed block; the planner doesn't try to find the exact protected register. Operator-visible diagnostic + prohibition stays correct. - Periodic re-probe to clear stale prohibitions. - Surface auto-prohibited ranges through GetHostStatuses or a new diagnostic so the Admin UI can show what's been auto-isolated.
This commit is contained in:
@@ -386,6 +386,43 @@ public sealed class ModbusDriver
|
||||
/// <summary>Resolve the UnitId for a tag — per-tag override (#142) or driver-level fallback.</summary>
|
||||
private byte ResolveUnitId(ModbusTagDefinition tag) => tag.UnitId ?? _options.UnitId;
|
||||
|
||||
/// <summary>
|
||||
/// #148 — runtime-discovered ranges where coalesced reads have failed (typically because
|
||||
/// the PLC has a write-only or protected register mid-block). Subsequent scans skip
|
||||
/// coalescing across these ranges and let the per-tag fallback handle the members.
|
||||
/// Cleared by ReinitializeAsync (operator restart) or by an explicit re-probe API
|
||||
/// (not yet shipped).
|
||||
/// </summary>
|
||||
private readonly HashSet<(byte Unit, ModbusRegion Region, ushort Start, ushort End)> _autoProhibited = new();
|
||||
private readonly object _autoProhibitedLock = new();
|
||||
|
||||
private bool RangeIsAutoProhibited(byte unit, ModbusRegion region, ushort start, ushort end)
|
||||
{
|
||||
lock (_autoProhibitedLock)
|
||||
{
|
||||
foreach (var p in _autoProhibited)
|
||||
{
|
||||
// A candidate (start..end) range is prohibited if it overlaps any recorded
|
||||
// failure. Overlap rule: max-start ≤ min-end. We don't try to be smart about
|
||||
// partial overlap — once a range fails, any superset of it is also untrusted.
|
||||
if (p.Unit != unit || p.Region != region) continue;
|
||||
if (Math.Max(start, p.Start) <= Math.Min(end, p.End)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordAutoProhibition(byte unit, ModbusRegion region, ushort start, ushort end)
|
||||
{
|
||||
lock (_autoProhibitedLock) _autoProhibited.Add((unit, region, start, end));
|
||||
}
|
||||
|
||||
/// <summary>Test/diagnostic accessor — returns the current auto-prohibited range count.</summary>
|
||||
internal int AutoProhibitedRangeCount
|
||||
{
|
||||
get { lock (_autoProhibitedLock) return _autoProhibited.Count; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #143 block-read coalescing planner. Groups eligible tags by (UnitId, Region), sorts
|
||||
/// by start address, and merges adjacent / near-adjacent (gap ≤ MaxReadGap) into single
|
||||
@@ -438,7 +475,10 @@ public sealed class ModbusDriver
|
||||
var gap = tagStart - last.End - 1;
|
||||
var newEnd = Math.Max(tagEnd, last.End);
|
||||
var newSpan = newEnd - last.Start + 1;
|
||||
if (gap <= _options.MaxReadGap && newSpan <= cap)
|
||||
// #148 — skip merges that would re-attempt a known-bad range. The
|
||||
// per-tag fallback will read each member individually instead.
|
||||
var crossesProhibition = RangeIsAutoProhibited(group.Key.Unit, group.Key.Region, last.Start, (ushort)newEnd);
|
||||
if (gap <= _options.MaxReadGap && newSpan <= cap && !crossesProhibition)
|
||||
{
|
||||
last.Members.Add((idx, tag));
|
||||
blocks[^1] = (last.Start, (ushort)newEnd, last.Members);
|
||||
@@ -477,16 +517,30 @@ public sealed class ModbusDriver
|
||||
}
|
||||
catch (ModbusException mex)
|
||||
{
|
||||
// #148 — record the failed range so the planner stops re-coalescing across
|
||||
// it on subsequent scans. Per-tag fallback reads each member individually
|
||||
// next time, so healthy tags around the protected hole keep working without
|
||||
// operator intervention.
|
||||
RecordAutoProhibition(group.Key.Unit, group.Key.Region, block.Start, block.End);
|
||||
|
||||
var status = MapModbusExceptionToStatus(mex.ExceptionCode);
|
||||
foreach (var (idx, _) in block.Members)
|
||||
{
|
||||
results[idx] = new DataValueSnapshot(null, status, null, timestamp);
|
||||
handled.Add(idx);
|
||||
// Don't mark members handled — leave them for the per-tag fallback in
|
||||
// the same scan so single-register reads can succeed for any non-
|
||||
// protected member. (Pre-#148 behaviour was to mark all Bad and skip.)
|
||||
// Members that ARE the protected register will fail again at single-tag
|
||||
// granularity and surface the per-tag exception code naturally.
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, mex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Communication failures (timeout, socket drop) aren't a structural reason
|
||||
// to prohibit the range — the same coalesced read might succeed once the
|
||||
// transport recovers. Mark members Bad for this scan but don't auto-prohibit
|
||||
// and don't deflect to per-tag fallback (which would just hit the same dead
|
||||
// socket).
|
||||
foreach (var (idx, _) in block.Members)
|
||||
{
|
||||
results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, timestamp);
|
||||
|
||||
Reference in New Issue
Block a user