@@ -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
|
||||
|
||||
Reference in New Issue
Block a user