@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
124
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHsbyRoleProber.cs
Normal file
124
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHsbyRoleProber.cs
Normal 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 & 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user