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

@@ -40,6 +40,18 @@ public abstract class AbCipCommandBase : DriverCommandBase
"walk; unsupported on Micro800 (silent fallback to Symbolic with warning).")]
public AddressingMode AddressingMode { get; init; } = AddressingMode.Auto;
/// <summary>
/// PR abcip-5.1 — partner gateway URI for HSBY (Hot-Standby) paired chassis. When
/// supplied, every CLI command auto-enables HSBY role probing on the device options
/// so subcommands like <c>hsby-status</c> + diagnostics surface the active chassis
/// without extra flags. Unset for non-redundant deployments.
/// </summary>
[CommandOption("partner", Description =
"Partner gateway URI for ControlLogix HSBY pair (e.g. ab://10.0.0.6/1,0). When " +
"set, the driver runs a second role-probe loop and the hsby-status command can " +
"surface which chassis is currently Active. Optional.")]
public string? Partner { get; init; }
/// <inheritdoc />
public override TimeSpan Timeout
{
@@ -58,7 +70,17 @@ public abstract class AbCipCommandBase : DriverCommandBase
HostAddress: Gateway,
PlcFamily: Family,
DeviceName: $"cli-{Family}",
AddressingMode: AddressingMode)],
AddressingMode: AddressingMode,
// PR abcip-5.1 — surface --partner through the device options so commands that
// use BuildOptions can take advantage of HSBY role probing without subclassing.
// Hsby auto-enables only when a partner was actually supplied; pre-5.1 invocations
// (no --partner) see exactly the legacy options shape.
PartnerHostAddress: Partner,
Hsby: string.IsNullOrWhiteSpace(Partner) ? null : new AbCipHsbyOptions
{
Enabled = true,
ProbeInterval = TimeSpan.FromSeconds(2),
})],
Tags = tags,
Timeout = Timeout,
Probe = new AbCipProbeOptions { Enabled = false },

View File

@@ -0,0 +1,103 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
/// <summary>
/// PR abcip-5.1 — print the current HSBY role on each chassis of a paired ControlLogix
/// ControlLogix Hot-Standby setup. Requires <c>--partner</c> on the base command +
/// reads <c>WallClockTime.SyncStatus</c> on both gateways once before printing.
/// </summary>
[Command("hsby-status", Description =
"Read the WallClockTime.SyncStatus role tag on a ControlLogix HSBY pair and print " +
"which chassis is currently Active. Requires --partner.")]
public sealed class HsbyStatusCommand : AbCipCommandBase
{
[CommandOption("role-tag", Description =
"Role-tag address. Default WallClockTime.SyncStatus matches v20+ ControlLogix HSBY; " +
"use S:34 for legacy SLC500 / PLC-5 status-byte fronts.")]
public string RoleTagAddress { get; init; } = "WallClockTime.SyncStatus";
[CommandOption("samples", Description =
"Number of role-probe ticks to wait for before printing (default 3). Larger values " +
"give the role-prober loop more chances to sample both chassis through transient " +
"transport hiccups.")]
public int Samples { get; init; } = 3;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
if (string.IsNullOrWhiteSpace(Partner))
{
await console.Error.WriteLineAsync(
"hsby-status requires --partner <ab://gateway/cip-path>. Without a partner the " +
"command has no second chassis to compare roles against.");
return;
}
// Override the base BuildOptions so we can pin the role-tag address + a tight probe
// interval — the default 2 s would mean Samples * 2 s before the print fires, too slow
// for an interactive CLI. Tag list stays empty; only the role probe runs.
var options = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(
HostAddress: Gateway,
PlcFamily: Family,
DeviceName: $"cli-{Family}",
AddressingMode: AddressingMode,
PartnerHostAddress: Partner,
Hsby: new AbCipHsbyOptions
{
Enabled = true,
RoleTagAddress = RoleTagAddress,
ProbeInterval = TimeSpan.FromMilliseconds(500),
})],
Tags = [],
Timeout = Timeout,
Probe = new AbCipProbeOptions { Enabled = false },
EnableControllerBrowse = false,
EnableAlarmProjection = false,
};
await using var driver = new AbCipDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
// Wait Samples * ProbeInterval so the role probe has had time to sample each
// chassis at least <Samples> times. The role probe loop spins inside the driver;
// we just sleep + read GetDeviceState's ActiveAddress.
await Task.Delay(TimeSpan.FromMilliseconds(500 * Math.Max(1, Samples)), ct);
// Pull HSBY state out via DriverHealth.Diagnostics. Single-pair config emits
// the flat AbCip.HsbyActive / AbCip.HsbyPrimaryRole / AbCip.HsbyPartnerRole keys.
var diag = driver.GetHealth().Diagnostics
?? new Dictionary<string, double>();
var primaryRole = diag.TryGetValue("AbCip.HsbyPrimaryRole", out var pr)
? (HsbyRole)(int)pr : HsbyRole.Unknown;
var partnerRole = diag.TryGetValue("AbCip.HsbyPartnerRole", out var qr)
? (HsbyRole)(int)qr : HsbyRole.Unknown;
var activeCode = diag.TryGetValue("AbCip.HsbyActive", out var ac) ? (int)ac : 0;
var activeAddress = activeCode switch
{
1 => Gateway,
2 => Partner,
_ => null,
};
await console.Output.WriteLineAsync($"Primary: {Gateway}");
await console.Output.WriteLineAsync($"Partner: {Partner}");
await console.Output.WriteLineAsync($"Role tag: {RoleTagAddress}");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync($"Primary role: {primaryRole}");
await console.Output.WriteLineAsync($"Partner role: {partnerRole}");
await console.Output.WriteLineAsync($"Active chassis: {activeAddress ?? "<none>"}");
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
}

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

View File

@@ -43,7 +43,15 @@ public static class AbCipDriverFactoryExtensions
"AddressingMode", fallback: AddressingMode.Auto),
ReadStrategy: ParseEnum<ReadStrategy>(d.ReadStrategy, "device", driverInstanceId,
"ReadStrategy", fallback: ReadStrategy.Auto),
MultiPacketSparsityThreshold: d.MultiPacketSparsityThreshold ?? 0.25))]
MultiPacketSparsityThreshold: d.MultiPacketSparsityThreshold ?? 0.25,
// PR abcip-5.1 — HSBY paired-IP knobs. Both null / absent = no HSBY.
PartnerHostAddress: d.PartnerHostAddress,
Hsby: d.Hsby is null ? null : new AbCipHsbyOptions
{
Enabled = d.Hsby.Enabled ?? false,
RoleTagAddress = d.Hsby.RoleTagAddress ?? "WallClockTime.SyncStatus",
ProbeInterval = TimeSpan.FromMilliseconds(d.Hsby.ProbeIntervalMs ?? 2_000),
}))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
@@ -163,6 +171,32 @@ public static class AbCipDriverFactoryExtensions
/// resolves to <c>Auto</c>. Default <c>0.25</c>; clamped to <c>[0..1]</c>.
/// </summary>
public double? MultiPacketSparsityThreshold { get; init; }
/// <summary>
/// PR abcip-5.1 — canonical AB CIP gateway URI of the partner chassis in a
/// ControlLogix HSBY pair. <c>null</c> = no HSBY partner; the driver behaves
/// exactly like every pre-5.1 build. When set together with
/// <see cref="Hsby"/> <c>.Enabled = true</c>, the driver runs a second probe loop
/// against the partner + reports the active chassis through driver diagnostics.
/// </summary>
public string? PartnerHostAddress { get; init; }
/// <summary>
/// PR abcip-5.1 — HSBY (Hot-Standby) sub-options. Defaults to
/// <c>Enabled = false</c> when omitted; pre-5.1 deployments are unaffected.
/// </summary>
public AbCipHsbyDto? Hsby { get; init; }
}
/// <summary>
/// PR abcip-5.1 — JSON-mirror of <see cref="AbCipHsbyOptions"/>. Off by default; enabled
/// by setting <c>Enabled = true</c> + the parent device's <c>PartnerHostAddress</c>.
/// </summary>
internal sealed class AbCipHsbyDto
{
public bool? Enabled { get; init; }
public string? RoleTagAddress { get; init; }
public int? ProbeIntervalMs { get; init; }
}
internal sealed class AbCipTagDto

View File

@@ -151,6 +151,23 @@ public sealed class AbCipDriverOptions
/// where the wire-cost of one whole-UDT read still beats N member reads on ControlLogix's
/// 4002-byte connection size; see <c>docs/drivers/AbCip-Performance.md</c> §"Read strategy".
/// Clamped to <c>[0..1]</c> at planner time; values outside the range silently saturate.</param>
/// <param name="PartnerHostAddress">PR abcip-5.1 — optional canonical AB CIP gateway URI of the
/// partner chassis in a ControlLogix HSBY (Hot-Standby) pair. When set together with
/// <paramref name="Hsby"/><c>.Enabled = true</c>, the driver runs a second probe loop against
/// this partner address + uses the configured role tag (default
/// <c>WallClockTime.SyncStatus</c>, fall-back <c>S:34</c> for PLC-5 / SLC-style fronts) to
/// determine which chassis is currently Active. PR abcip-5.1 only **discovers + reports**
/// the active chassis through driver diagnostics; PR abcip-5.2 is the follow-up that wires
/// the resolved active address into <see cref="AbCipDriver.ResolveHost"/> for live read /
/// write routing. <c>null</c> = no HSBY partner; the driver behaves exactly like every
/// pre-5.1 build.</param>
/// <param name="Hsby">PR abcip-5.1 — HSBY (Hot-Standby) sub-options. Defaults to
/// <c>Enabled = false</c> so back-compat deployments that don't set
/// <see cref="PartnerHostAddress"/> see no behaviour change. <see cref="AbCipHsbyOptions.Enabled"/>
/// gates the second probe loop + role-tag read; <see cref="AbCipHsbyOptions.RoleTagAddress"/>
/// picks <c>WallClockTime.SyncStatus</c> (v20+ ControlLogix) vs <c>S:34</c> (legacy
/// SLC500 / PLC-5 status byte fallback); <see cref="AbCipHsbyOptions.ProbeInterval"/>
/// controls the role-tag poll cadence.</param>
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
@@ -158,7 +175,52 @@ public sealed record AbCipDeviceOptions(
int? ConnectionSize = null,
AddressingMode AddressingMode = AddressingMode.Auto,
ReadStrategy ReadStrategy = ReadStrategy.Auto,
double MultiPacketSparsityThreshold = 0.25);
double MultiPacketSparsityThreshold = 0.25,
string? PartnerHostAddress = null,
AbCipHsbyOptions? Hsby = null);
/// <summary>
/// PR abcip-5.1 — HSBY (Hot-Standby) per-device options. Off by default. When
/// <see cref="Enabled"/> = <c>true</c> + the device sets
/// <see cref="AbCipDeviceOptions.PartnerHostAddress"/>, the driver runs two probe loops
/// concurrently — primary <see cref="AbCipDeviceOptions.HostAddress"/> + the partner —
/// reads the configured role tag on each, and reports which chassis is Active through
/// driver diagnostics (<c>AbCip.HsbyActive</c>, <c>AbCip.HsbyPrimaryRole</c>,
/// <c>AbCip.HsbyPartnerRole</c>). PR abcip-5.2 is the follow-up that wires the resolved
/// active address back into <see cref="AbCipDriver.ResolveHost"/> for live read / write
/// routing — 5.1 just gathers the role.
/// </summary>
/// <remarks>
/// Role-tag detection matrix:
/// <list type="bullet">
/// <item><b>v20 / v24 / v32+ ControlLogix HSBY</b> — <c>WallClockTime.SyncStatus</c>
/// (DINT). Values: <c>0</c> = Standby (Synchronized but not Active),
/// <c>1</c> = Synchronized / Active (active chassis), <c>2</c> = Disqualified.</item>
/// <item><b>PLC-5 / SLC500 fallback</b> — <c>S:34</c> Module Status word (PLC-5 has a
/// role bit in word 34 of the status file). Bit 0 = "this chassis is Active". This
/// is the legacy fallback for sites that haven't migrated to ControlLogix HSBY.</item>
/// </list>
/// </remarks>
public sealed record AbCipHsbyOptions
{
/// <summary>Master switch. Default <c>false</c> — no role probing, no second probe loop.</summary>
public bool Enabled { get; init; }
/// <summary>
/// Address of the role tag the driver reads on each probe tick. Default
/// <c>WallClockTime.SyncStatus</c> matches v20+ ControlLogix HSBY firmware. Legacy
/// PLC-5 / SLC500 fronts that expose a status-file role bit pass <c>S:34</c> here +
/// the role prober applies the bit-mask interpretation automatically.
/// </summary>
public string RoleTagAddress { get; init; } = "WallClockTime.SyncStatus";
/// <summary>
/// Cadence the HSBY role probe ticks at. Default 2 seconds — tight enough to detect
/// a manual switch-over within one Admin-UI refresh, loose enough to leave headroom
/// for the regular probe loop on the same gateway.
/// </summary>
public TimeSpan ProbeInterval { get; init; } = TimeSpan.FromSeconds(2);
}
/// <summary>
/// PR abcip-3.3 — per-device strategy for reading multi-member UDT batches. <see cref="WholeUdt"/>

View File

@@ -0,0 +1,124 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// PR abcip-5.1 — resolved HSBY role for one chassis in a ControlLogix Hot-Standby pair.
/// <see cref="Unknown"/> covers "couldn't read the role tag" (transport failure, tag not
/// found, decode failure); the driver treats it as "no information yet, don't change
/// ActiveAddress" rather than as a vote for Standby.
/// </summary>
public enum HsbyRole
{
/// <summary>Read failed or value was not decodable. Surface as "no information".</summary>
Unknown = 0,
/// <summary>Chassis is the active member of the HSBY pair (Synchronized + serving I/O).</summary>
Active = 1,
/// <summary>Chassis is the standby member — Synchronized but not driving I/O.</summary>
Standby = 2,
/// <summary>Chassis has been disqualified by the HSBY module (e.g. firmware mismatch).</summary>
Disqualified = 3,
}
/// <summary>
/// PR abcip-5.1 — reads a ControlLogix HSBY role tag from one chassis and maps the value
/// to <see cref="HsbyRole"/>. Two address formats are supported:
/// <list type="bullet">
/// <item><b>v20 / v24 / v32+ ControlLogix HSBY</b> — <c>WallClockTime.SyncStatus</c>
/// (DINT-typed). Values: <c>0 = Standby</c>, <c>1 = Synchronized / Active</c>,
/// <c>2 = Disqualified</c>. Other values map to <see cref="HsbyRole.Unknown"/>.</item>
/// <item><b>PLC-5 / SLC500 fallback</b> — <c>S:34</c> Module Status word. Bit 0 of the
/// integer value indicates "this chassis is Active"; the prober applies the
/// bit-mask interpretation when the address starts with <c>"S:"</c> + maps
/// <c>(value &amp; 1) == 1 → Active</c>, otherwise → Standby.</item>
/// </list>
/// Read failure (initialise / read throw, non-zero libplctag status, undecodable buffer)
/// returns <see cref="HsbyRole.Unknown"/> — callers (the driver's HSBY probe loop)
/// interpret Unknown as "leave ActiveAddress alone for this tick".
/// </summary>
/// <remarks>
/// The prober is stateless / static — the per-chassis runtime is provided by
/// <see cref="AbCipDriver.ProbeLoopAsync"/> + drives initialise / read on the runtime
/// before delegating to <see cref="ProbeAsync"/>. Keeping the value-mapping logic isolated
/// here lets unit tests assert the matrix (0 / 1 / 2 / S:34 bit 0 / unknown values) without
/// standing up a probe loop.
/// </remarks>
public static class AbCipHsbyRoleProber
{
/// <summary>
/// Read <paramref name="roleTagAddress"/> on <paramref name="runtime"/> + map the
/// decoded value to a <see cref="HsbyRole"/>. The runtime is already initialised by
/// the caller (<see cref="AbCipDriver.ProbeLoopAsync"/> shares the same lazy-init
/// pattern with the regular probe loop); this method only issues the read + decodes.
/// </summary>
public static async Task<HsbyRole> ProbeAsync(
IAbCipTagRuntime runtime, string roleTagAddress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentException.ThrowIfNullOrWhiteSpace(roleTagAddress);
try
{
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
if (runtime.GetStatus() != 0) return HsbyRole.Unknown;
var raw = runtime.DecodeValue(AbCipDataType.DInt, bitIndex: null);
return MapValueToRole(raw, roleTagAddress);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
// Wire / init / decode failure — surface as Unknown so the caller doesn't
// misinterpret a transient transport hiccup as "this chassis went Standby".
return HsbyRole.Unknown;
}
}
/// <summary>
/// Pure value-to-role mapper. Exposed for unit tests so the matrix assertions can run
/// without a runtime in scope. <see cref="ProbeAsync"/> is the production entry point.
/// </summary>
public static HsbyRole MapValueToRole(object? raw, string roleTagAddress)
{
if (raw is null) return HsbyRole.Unknown;
if (!TryToInt64(raw, out var value)) return HsbyRole.Unknown;
// PLC-5 / SLC500 status-file fallback — bit 0 of S:34 is the role bit. Pattern-match
// on the "S:" prefix because operators do put the file number after it (S:34, S:2,
// etc) + the role bit lives in S:34 specifically on PLC-5 fronts but the bit-mask
// semantics apply to any S:NN address an integration plumbs in.
if (roleTagAddress.StartsWith("S:", StringComparison.OrdinalIgnoreCase))
return (value & 1) == 1 ? HsbyRole.Active : HsbyRole.Standby;
// Default — WallClockTime.SyncStatus matrix (v20 / v24 / v32+ ControlLogix HSBY).
return value switch
{
0 => HsbyRole.Standby,
1 => HsbyRole.Active,
2 => HsbyRole.Disqualified,
_ => HsbyRole.Unknown,
};
}
private static bool TryToInt64(object raw, out long value)
{
switch (raw)
{
case long l: value = l; return true;
case int i: value = i; return true;
case short s: value = s; return true;
case sbyte sb: value = sb; return true;
case byte b: value = b; return true;
case ushort us: value = us; return true;
case uint ui: value = ui; return true;
case ulong ul when ul <= long.MaxValue: value = (long)ul; return true;
case bool boolean: value = boolean ? 1 : 0; return true;
case string str when long.TryParse(str, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var parsed):
value = parsed; return true;
default: value = 0; return false;
}
}
}