Auto: abcip-5.1 — HSBY paired-IP role probing

Closes #242
This commit is contained in:
Joseph Doherty
2026-04-26 07:51:44 -04:00
parent 349aa5c6f4
commit 561b0f9ea9
12 changed files with 1260 additions and 9 deletions

View File

@@ -247,6 +247,22 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
// PR abcip-5.1 — HSBY role-probe loops. Independent of the connectivity-probe loop
// above; one role-prober task per (primary, partner) pair. Disabled by default; an
// operator opts in by setting Hsby.Enabled = true + PartnerHostAddress on the
// device options. The probe reads WallClockTime.SyncStatus (or S:34) on each
// chassis + updates DeviceState.PrimaryRole / PartnerRole / ActiveAddress.
foreach (var state in _devices.Values)
{
if (state.Options.Hsby is { Enabled: true } hsby
&& !string.IsNullOrWhiteSpace(state.Options.PartnerHostAddress))
{
state.PartnerAddress = state.Options.PartnerHostAddress;
state.HsbyCts = new CancellationTokenSource();
var ct = state.HsbyCts.Token;
_ = Task.Run(() => HsbyProbeLoopAsync(state, hsby, ct), ct);
}
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
@@ -424,6 +440,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try { state.ProbeCts?.Cancel(); } catch { }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
// PR abcip-5.1 — also tear down the HSBY role-probe loop if one is running.
try { state.HsbyCts?.Cancel(); } catch { }
state.HsbyCts?.Dispose();
state.HsbyCts = null;
state.DisposeHandles();
}
_devices.Clear();
@@ -644,6 +664,178 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try { probeRuntime?.Dispose(); } catch { }
}
/// <summary>
/// PR abcip-5.1 — HSBY role-probe loop. Concurrently reads the configured role tag
/// (default <c>WallClockTime.SyncStatus</c>) on the primary chassis (the device's own
/// <see cref="AbCipHostAddress"/>) and on the partner address (parsed from
/// <see cref="AbCipDeviceOptions.PartnerHostAddress"/>), maps each via
/// <see cref="AbCipHsbyRoleProber"/>, and updates the device's
/// <see cref="DeviceState.PrimaryRole"/> / <see cref="DeviceState.PartnerRole"/> /
/// <see cref="DeviceState.ActiveAddress"/>.
/// <para>
/// Active-resolution rules:
/// <list type="bullet">
/// <item>Primary <see cref="HsbyRole.Active"/>, partner not Active → ActiveAddress = primary.</item>
/// <item>Partner Active, primary not Active → ActiveAddress = partner.</item>
/// <item>Both Active → primary wins (warns via <see cref="AbCipDriverOptions.OnWarning"/> sink).</item>
/// <item>Neither Active (Standby / Disqualified / Unknown) → ActiveAddress = null
/// so PR abcip-5.2's <c>ResolveHost</c> can surface BadCommunicationError.</item>
/// </list>
/// PR abcip-5.1 only **gathers** the role + reports it through driver diagnostics.
/// PR abcip-5.2 will plumb the resolved active address back into
/// <see cref="ResolveHost"/> for live read/write routing.
/// </para>
/// </summary>
private async Task HsbyProbeLoopAsync(DeviceState state, AbCipHsbyOptions hsby, CancellationToken ct)
{
var partnerAddress = state.Options.PartnerHostAddress;
if (string.IsNullOrWhiteSpace(partnerAddress)) return;
var partnerParsed = AbCipHostAddress.TryParse(partnerAddress);
if (partnerParsed is null)
{
_options.OnWarning?.Invoke(
$"AbCip device '{state.Options.HostAddress}' has invalid PartnerHostAddress " +
$"'{partnerAddress}' — expected 'ab://gateway[:port]/cip-path'. HSBY role probing disabled.");
return;
}
// Per-chassis runtime params. Both chassis share the device's family / ConnectionSize
// / addressing-mode resolution so the role-tag read uses the same wire conventions as
// a regular tag read on either side.
var primaryParams = new AbCipTagCreateParams(
Gateway: state.ParsedAddress.Gateway,
Port: state.ParsedAddress.Port,
CipPath: state.ParsedAddress.CipPath,
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
TagName: hsby.RoleTagAddress,
Timeout: _options.Probe.Timeout,
ConnectionSize: state.ConnectionSize,
AddressingMode: AddressingMode.Symbolic);
var partnerParams = primaryParams with
{
Gateway = partnerParsed.Gateway,
Port = partnerParsed.Port,
CipPath = partnerParsed.CipPath,
};
IAbCipTagRuntime? primaryRuntime = null;
IAbCipTagRuntime? partnerRuntime = null;
var primaryInitialized = false;
var partnerInitialized = false;
try
{
while (!ct.IsCancellationRequested)
{
var primaryRoleTask = ProbeOneAsync(
primaryParams,
() => primaryRuntime,
rt => primaryRuntime = rt,
() => primaryInitialized,
v => primaryInitialized = v,
hsby.RoleTagAddress,
ct);
var partnerRoleTask = ProbeOneAsync(
partnerParams,
() => partnerRuntime,
rt => partnerRuntime = rt,
() => partnerInitialized,
v => partnerInitialized = v,
hsby.RoleTagAddress,
ct);
HsbyRole primaryRole, partnerRole;
try
{
var roles = await Task.WhenAll(primaryRoleTask, partnerRoleTask).ConfigureAwait(false);
primaryRole = roles[0];
partnerRole = roles[1];
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
}
state.PrimaryRole = primaryRole;
state.PartnerRole = partnerRole;
string? newActive;
if (primaryRole == HsbyRole.Active && partnerRole == HsbyRole.Active)
{
// Split-brain — both chassis claim Active. Primary wins (deterministic
// tie-break) + we shout via the warning sink so operators see it.
_options.OnWarning?.Invoke(
$"AbCip HSBY split-brain detected on pair " +
$"primary='{state.Options.HostAddress}' partner='{partnerAddress}' — both " +
$"chassis report Active; routing to primary.");
newActive = state.Options.HostAddress;
}
else if (primaryRole == HsbyRole.Active)
{
newActive = state.Options.HostAddress;
}
else if (partnerRole == HsbyRole.Active)
{
newActive = partnerAddress;
}
else
{
// No chassis Active — clear so PR abcip-5.2's ResolveHost can fault writes.
newActive = null;
}
state.ActiveAddress = newActive;
try { await Task.Delay(hsby.ProbeInterval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
finally
{
try { primaryRuntime?.Dispose(); } catch { }
try { partnerRuntime?.Dispose(); } catch { }
}
async Task<HsbyRole> ProbeOneAsync(
AbCipTagCreateParams createParams,
Func<IAbCipTagRuntime?> get,
Action<IAbCipTagRuntime?> set,
Func<bool> getInit,
Action<bool> setInit,
string roleTagAddress,
CancellationToken token)
{
try
{
var rt = get();
if (rt is null)
{
rt = _tagFactory.Create(createParams);
set(rt);
}
if (!getInit())
{
await rt.InitializeAsync(token).ConfigureAwait(false);
setInit(true);
}
return await AbCipHsbyRoleProber.ProbeAsync(rt, roleTagAddress, token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
throw;
}
catch
{
// Tear down so the next tick re-creates the runtime; this matches the regular
// probe loop's recovery pattern.
try { get()?.Dispose(); } catch { }
set(null);
setInit(false);
return HsbyRole.Unknown;
}
}
}
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
@@ -1751,13 +1943,53 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// counters (Forward Open count, multi-service-packet ratio, etc.) by extending this
/// dictionary.
/// </summary>
private IReadOnlyDictionary<string, double> BuildDiagnostics() => new Dictionary<string, double>
private IReadOnlyDictionary<string, double> BuildDiagnostics()
{
["AbCip.WritesSuppressed"] = _writeCoalescer.TotalWritesSuppressed,
["AbCip.WritesPassedThrough"] = _writeCoalescer.TotalWritesPassedThrough,
// PR abcip-4.4 — total _RefreshTagDb truthy writes that dispatched to RebrowseAsync.
["AbCip.RefreshTriggers"] = _systemTagSource.TotalRefreshTriggers,
};
var dict = new Dictionary<string, double>
{
["AbCip.WritesSuppressed"] = _writeCoalescer.TotalWritesSuppressed,
["AbCip.WritesPassedThrough"] = _writeCoalescer.TotalWritesPassedThrough,
// PR abcip-4.4 — total _RefreshTagDb truthy writes that dispatched to RebrowseAsync.
["AbCip.RefreshTriggers"] = _systemTagSource.TotalRefreshTriggers,
};
// PR abcip-5.1 — HSBY role surface. One <Counter> per HSBY-enabled device:
// AbCip.HsbyActive — 1 if ActiveAddress == primary, 2 if == partner, 0 otherwise.
// AbCip.HsbyPrimaryRole — most-recent (HsbyRole)int observed on the primary.
// AbCip.HsbyPartnerRole — most-recent (HsbyRole)int observed on the partner.
// The single-driver case (one HSBY pair) collapses these to flat keys; multi-pair
// configurations get scoped keys per host so the Admin UI can render each pair.
var hsbyDevices = _devices.Values
.Where(d => d.Options.Hsby is { Enabled: true } && !string.IsNullOrWhiteSpace(d.Options.PartnerHostAddress))
.ToList();
if (hsbyDevices.Count == 1)
{
var d = hsbyDevices[0];
dict["AbCip.HsbyActive"] = HsbyActiveCode(d);
dict["AbCip.HsbyPrimaryRole"] = (int)d.PrimaryRole;
dict["AbCip.HsbyPartnerRole"] = (int)d.PartnerRole;
}
else
{
foreach (var d in hsbyDevices)
{
var key = d.Options.HostAddress;
dict[$"AbCip.HsbyActive[{key}]"] = HsbyActiveCode(d);
dict[$"AbCip.HsbyPrimaryRole[{key}]"] = (int)d.PrimaryRole;
dict[$"AbCip.HsbyPartnerRole[{key}]"] = (int)d.PartnerRole;
}
}
return dict;
static double HsbyActiveCode(DeviceState d)
{
if (d.ActiveAddress is null) return 0;
if (string.Equals(d.ActiveAddress, d.Options.HostAddress, StringComparison.OrdinalIgnoreCase))
return 1;
if (string.Equals(d.ActiveAddress, d.PartnerAddress, StringComparison.OrdinalIgnoreCase))
return 2;
return 0;
}
}
/// <summary>
/// Test seam — exposes the live coalescer for unit tests that want to inspect counters
@@ -2120,6 +2352,31 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public CancellationTokenSource? ProbeCts { get; set; }
public bool ProbeInitialized { get; set; }
/// <summary>
/// PR abcip-5.1 — currently active chassis address in an HSBY pair, or
/// <c>null</c> when (a) HSBY isn't configured for this device or (b) neither
/// chassis returned <see cref="HsbyRole.Active"/> on the latest probe tick.
/// PR abcip-5.2 will consult this in <see cref="AbCipDriver.ResolveHost"/> to
/// route reads / writes; PR 5.1 only reports it through driver diagnostics.
/// </summary>
public string? ActiveAddress { get; set; }
/// <summary>
/// PR abcip-5.1 — partner chassis address pulled from
/// <see cref="AbCipDeviceOptions.PartnerHostAddress"/> at init. <c>null</c> when
/// HSBY isn't configured.
/// </summary>
public string? PartnerAddress { get; set; }
/// <summary>PR abcip-5.1 — most-recent role observed on the primary chassis.</summary>
public HsbyRole PrimaryRole { get; set; } = HsbyRole.Unknown;
/// <summary>PR abcip-5.1 — most-recent role observed on the partner chassis.</summary>
public HsbyRole PartnerRole { get; set; } = HsbyRole.Unknown;
/// <summary>PR abcip-5.1 — cancellation source for the HSBY probe loop. Disposed at shutdown.</summary>
public CancellationTokenSource? HsbyCts { get; set; }
/// <summary>
/// PR abcip-4.3 — wall-clock duration of the most recent <see cref="AbCipDriver.ReadAsync"/>
/// iteration that touched any tag on this device, in milliseconds. Surfaces as