Compare commits
5 Commits
abcip-pr7-
...
70f5f2cad1
| Author | SHA1 | Date | |
|---|---|---|---|
| 70f5f2cad1 | |||
|
|
60b8d6f2d0 | ||
| 30f971599e | |||
|
|
ac14ba9664 | ||
| 5978ea002d |
@@ -20,7 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|||||||
/// from native-heap growth that the CLR allocator can't see; it tears down every
|
/// from native-heap growth that the CLR allocator can't see; it tears down every
|
||||||
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IDisposable, IAsyncDisposable
|
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||||
|
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly AbCipDriverOptions _options;
|
private readonly AbCipDriverOptions _options;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
@@ -33,6 +34,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||||
|
|
||||||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||||||
IAbCipTagFactory? tagFactory = null,
|
IAbCipTagFactory? tagFactory = null,
|
||||||
@@ -70,9 +72,6 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
foreach (var tag in _options.Tags)
|
foreach (var tag in _options.Tags)
|
||||||
{
|
{
|
||||||
// UDT tags with declared Members fan out into synthetic member-tag entries addressable
|
|
||||||
// by composed full-reference. Parent structure tag also stored so discovery can emit a
|
|
||||||
// folder for it.
|
|
||||||
_tagsByName[tag.Name] = tag;
|
_tagsByName[tag.Name] = tag;
|
||||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||||
{
|
{
|
||||||
@@ -89,6 +88,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Probe loops — one per device when enabled + a ProbeTagPath is configured.
|
||||||
|
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath))
|
||||||
|
{
|
||||||
|
foreach (var state in _devices.Values)
|
||||||
|
{
|
||||||
|
state.ProbeCts = new CancellationTokenSource();
|
||||||
|
var ct = state.ProbeCts.Token;
|
||||||
|
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -109,7 +119,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
{
|
{
|
||||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||||
foreach (var state in _devices.Values)
|
foreach (var state in _devices.Values)
|
||||||
|
{
|
||||||
|
try { state.ProbeCts?.Cancel(); } catch { }
|
||||||
|
state.ProbeCts?.Dispose();
|
||||||
|
state.ProbeCts = null;
|
||||||
state.DisposeHandles();
|
state.DisposeHandles();
|
||||||
|
}
|
||||||
_devices.Clear();
|
_devices.Clear();
|
||||||
_tagsByName.Clear();
|
_tagsByName.Clear();
|
||||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
@@ -127,6 +142,90 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- IHostConnectivityProbe ----
|
||||||
|
|
||||||
|
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||||
|
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||||
|
|
||||||
|
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var probeParams = new AbCipTagCreateParams(
|
||||||
|
Gateway: state.ParsedAddress.Gateway,
|
||||||
|
Port: state.ParsedAddress.Port,
|
||||||
|
CipPath: state.ParsedAddress.CipPath,
|
||||||
|
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||||
|
TagName: _options.Probe.ProbeTagPath!,
|
||||||
|
Timeout: _options.Probe.Timeout);
|
||||||
|
|
||||||
|
IAbCipTagRuntime? probeRuntime = null;
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var success = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
probeRuntime ??= _tagFactory.Create(probeParams);
|
||||||
|
// Lazy-init on first attempt; re-init after a transport failure has caused the
|
||||||
|
// native handle to be destroyed.
|
||||||
|
if (!state.ProbeInitialized)
|
||||||
|
{
|
||||||
|
await probeRuntime.InitializeAsync(ct).ConfigureAwait(false);
|
||||||
|
state.ProbeInitialized = true;
|
||||||
|
}
|
||||||
|
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||||
|
success = probeRuntime.GetStatus() == 0;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Wire / init error — tear down the probe runtime so the next tick re-creates it.
|
||||||
|
try { probeRuntime?.Dispose(); } catch { }
|
||||||
|
probeRuntime = null;
|
||||||
|
state.ProbeInitialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||||
|
|
||||||
|
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
try { probeRuntime?.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||||
|
{
|
||||||
|
HostState old;
|
||||||
|
lock (state.ProbeLock)
|
||||||
|
{
|
||||||
|
old = state.HostState;
|
||||||
|
if (old == newState) return;
|
||||||
|
state.HostState = newState;
|
||||||
|
state.HostStateChangedUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
OnHostStatusChanged?.Invoke(this,
|
||||||
|
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IPerCallHostResolver ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve the device host address for a given tag full-reference. Per plan decision #144
|
||||||
|
/// the Phase 6.1 resilience pipeline keys its bulkhead + breaker on
|
||||||
|
/// <c>(DriverInstanceId, hostName)</c> so multi-PLC drivers get per-device isolation —
|
||||||
|
/// one dead PLC trips only its own breaker. Unknown references fall back to the
|
||||||
|
/// first configured device's host address rather than throwing — the invoker handles the
|
||||||
|
/// mislookup at the capability level when the actual read returns BadNodeIdUnknown.
|
||||||
|
/// </summary>
|
||||||
|
public string ResolveHost(string fullReference)
|
||||||
|
{
|
||||||
|
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||||
|
return def.DeviceHostAddress;
|
||||||
|
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- IReadable ----
|
// ---- IReadable ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -217,7 +316,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!def.Writable)
|
if (!def.Writable || def.SafetyTag)
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
|
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
|
||||||
continue;
|
continue;
|
||||||
@@ -422,7 +521,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||||
IsArray: false,
|
IsArray: false,
|
||||||
ArrayDim: null,
|
ArrayDim: null,
|
||||||
SecurityClass: tag.Writable
|
SecurityClass: (tag.Writable && !tag.SafetyTag)
|
||||||
? SecurityClassification.Operate
|
? SecurityClassification.Operate
|
||||||
: SecurityClassification.ViewOnly,
|
: SecurityClassification.ViewOnly,
|
||||||
IsHistorized: false,
|
IsHistorized: false,
|
||||||
@@ -457,6 +556,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public AbCipDeviceOptions Options { get; } = options;
|
public AbCipDeviceOptions Options { get; } = options;
|
||||||
public AbCipPlcFamilyProfile Profile { get; } = profile;
|
public AbCipPlcFamilyProfile Profile { get; } = profile;
|
||||||
|
|
||||||
|
public object ProbeLock { get; } = new();
|
||||||
|
public HostState HostState { get; set; } = HostState.Unknown;
|
||||||
|
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
public CancellationTokenSource? ProbeCts { get; set; }
|
||||||
|
public bool ProbeInitialized { get; set; }
|
||||||
|
|
||||||
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
|
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ public sealed record AbCipDeviceOptions(
|
|||||||
/// member (member TagPath = <c>{tag.TagPath}.{member.Name}</c>). When <c>null</c> on a Structure
|
/// member (member TagPath = <c>{tag.TagPath}.{member.Name}</c>). When <c>null</c> on a Structure
|
||||||
/// tag, the driver treats it as a black-box and relies on downstream configuration to address
|
/// tag, the driver treats it as a black-box and relies on downstream configuration to address
|
||||||
/// members individually via dotted <see cref="AbCipTagPath"/> syntax. Ignored for atomic types.</param>
|
/// members individually via dotted <see cref="AbCipTagPath"/> syntax. Ignored for atomic types.</param>
|
||||||
|
/// <param name="SafetyTag">GuardLogix safety-partition tag hint. When <c>true</c>, the driver
|
||||||
|
/// forces <c>SecurityClassification.ViewOnly</c> on discovery regardless of
|
||||||
|
/// <paramref name="Writable"/> — safety tags can only be written from the safety task of a
|
||||||
|
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are
|
||||||
|
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
|
||||||
|
/// write attempt failing at runtime.</param>
|
||||||
public sealed record AbCipTagDefinition(
|
public sealed record AbCipTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
string DeviceHostAddress,
|
string DeviceHostAddress,
|
||||||
@@ -66,7 +72,8 @@ public sealed record AbCipTagDefinition(
|
|||||||
AbCipDataType DataType,
|
AbCipDataType DataType,
|
||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false,
|
bool WriteIdempotent = false,
|
||||||
IReadOnlyList<AbCipStructureMember>? Members = null);
|
IReadOnlyList<AbCipStructureMember>? Members = null,
|
||||||
|
bool SafetyTag = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipHostProbeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHostStatuses_returns_one_entry_per_device()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||||
|
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var statuses = drv.GetHostStatuses();
|
||||||
|
statuses.Count.ShouldBe(2);
|
||||||
|
statuses.Select(s => s.HostName).ShouldBe(["ab://10.0.0.5/1,0", "ab://10.0.0.6/1,0"], ignoreOrder: true);
|
||||||
|
statuses.ShouldAllBe(s => s.State == HostState.Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_with_successful_read_transitions_to_Running()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = 0 } };
|
||||||
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Probe = new AbCipProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Interval = TimeSpan.FromMilliseconds(100),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(50),
|
||||||
|
ProbeTagPath = "@raw_cpu_type",
|
||||||
|
},
|
||||||
|
}, "drv-1", factory);
|
||||||
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
transitions.Select(t => t.NewState).ShouldContain(HostState.Running);
|
||||||
|
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_with_read_failure_transitions_to_Stopped()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory
|
||||||
|
{
|
||||||
|
Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true },
|
||||||
|
};
|
||||||
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Probe = new AbCipProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Interval = TimeSpan.FromMilliseconds(100),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(50),
|
||||||
|
ProbeTagPath = "@raw_cpu_type",
|
||||||
|
},
|
||||||
|
}, "drv-1", factory);
|
||||||
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_disabled_when_Enabled_is_false()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false, ProbeTagPath = "@raw_cpu_type" },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await Task.Delay(300);
|
||||||
|
|
||||||
|
transitions.ShouldBeEmpty();
|
||||||
|
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_skipped_when_ProbeTagPath_is_null()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = true, ProbeTagPath = null },
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_loops_across_multiple_devices_independently()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory
|
||||||
|
{
|
||||||
|
// Device A returns ok, Device B throws on read.
|
||||||
|
Customise = p => p.Gateway == "10.0.0.5"
|
||||||
|
? new FakeAbCipTag(p)
|
||||||
|
: new FakeAbCipTag(p) { ThrowOnRead = true },
|
||||||
|
};
|
||||||
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||||
|
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(50), ProbeTagPath = "@raw_cpu_type",
|
||||||
|
},
|
||||||
|
}, "drv-1", factory);
|
||||||
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await WaitForAsync(() => transitions.Count >= 2, TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
transitions.ShouldContain(t => t.HostName == "ab://10.0.0.5/1,0" && t.NewState == HostState.Running);
|
||||||
|
transitions.ShouldContain(t => t.HostName == "ab://10.0.0.6/1,0" && t.NewState == HostState.Stopped);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IPerCallHostResolver ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||||
|
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||||
|
],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||||
|
new AbCipTagDefinition("B", "ab://10.0.0.6/1,0", "B", AbCipDataType.DInt),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.ResolveHost("A").ShouldBe("ab://10.0.0.5/1,0");
|
||||||
|
drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveHost_falls_back_to_first_device_for_unknown_reference()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.ResolveHost("does-not-exist").ShouldBe("ab://10.0.0.5/1,0");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.ResolveHost("anything").ShouldBe("drv-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveHost_for_UDT_member_walks_to_synthesised_definition()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.7/1,0")],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Motor1", "ab://10.0.0.7/1,0", "Motor1", AbCipDataType.Structure,
|
||||||
|
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.ResolveHost("Motor1.Speed").ShouldBe("ab://10.0.0.7/1,0");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (!condition() && DateTime.UtcNow < deadline)
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipPlcFamilyTests
|
||||||
|
{
|
||||||
|
// ---- ControlLogix ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ControlLogix_profile_defaults_match_large_forward_open_baseline()
|
||||||
|
{
|
||||||
|
var p = AbCipPlcFamilyProfile.ControlLogix;
|
||||||
|
p.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||||
|
p.DefaultConnectionSize.ShouldBe(4002); // LFO — FW20+
|
||||||
|
p.DefaultCipPath.ShouldBe("1,0");
|
||||||
|
p.SupportsRequestPacking.ShouldBeTrue();
|
||||||
|
p.SupportsConnectedMessaging.ShouldBeTrue();
|
||||||
|
p.MaxFragmentBytes.ShouldBe(4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ControlLogix_device_initialises_with_correct_profile()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CompactLogix ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompactLogix_profile_uses_narrower_connection_size()
|
||||||
|
{
|
||||||
|
var p = AbCipPlcFamilyProfile.CompactLogix;
|
||||||
|
p.LibplctagPlcAttribute.ShouldBe("compactlogix");
|
||||||
|
p.DefaultConnectionSize.ShouldBe(504); // 5069-L3x narrow-window safety
|
||||||
|
p.DefaultCipPath.ShouldBe("1,0");
|
||||||
|
p.SupportsRequestPacking.ShouldBeTrue();
|
||||||
|
p.SupportsConnectedMessaging.ShouldBeTrue();
|
||||||
|
p.MaxFragmentBytes.ShouldBe(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CompactLogix_device_initialises_with_narrow_ConnectionSize()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://192.168.1.10/1,0", AbCipPlcFamily.CompactLogix)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var profile = drv.GetDeviceState("ab://192.168.1.10/1,0")!.Profile;
|
||||||
|
profile.DefaultConnectionSize.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
|
||||||
|
profile.MaxFragmentBytes.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.MaxFragmentBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Micro800 ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Micro800_profile_is_unconnected_only_with_empty_path()
|
||||||
|
{
|
||||||
|
var p = AbCipPlcFamilyProfile.Micro800;
|
||||||
|
p.LibplctagPlcAttribute.ShouldBe("micro800");
|
||||||
|
p.DefaultConnectionSize.ShouldBe(488);
|
||||||
|
p.DefaultCipPath.ShouldBe(""); // no backplane routing
|
||||||
|
p.SupportsRequestPacking.ShouldBeFalse();
|
||||||
|
p.SupportsConnectedMessaging.ShouldBeFalse();
|
||||||
|
p.MaxFragmentBytes.ShouldBe(484);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Micro800_device_with_empty_cip_path_parses_correctly()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var state = drv.GetDeviceState("ab://192.168.1.20/")!;
|
||||||
|
state.ParsedAddress.CipPath.ShouldBe("");
|
||||||
|
state.Profile.SupportsRequestPacking.ShouldBeFalse();
|
||||||
|
state.Profile.SupportsConnectedMessaging.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Micro800_read_forwards_empty_path_to_tag_create_params()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Value = 123 } };
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)],
|
||||||
|
Tags = [new AbCipTagDefinition("X", "ab://192.168.1.20/", "X", AbCipDataType.DInt)],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||||
|
factory.Tags["X"].CreationParams.CipPath.ShouldBe("");
|
||||||
|
factory.Tags["X"].CreationParams.LibplctagPlcAttribute.ShouldBe("micro800");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GuardLogix ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GuardLogix_profile_wire_protocol_mirrors_ControlLogix()
|
||||||
|
{
|
||||||
|
var p = AbCipPlcFamilyProfile.GuardLogix;
|
||||||
|
// Wire protocol is identical to ControlLogix — only the safety-partition semantics differ,
|
||||||
|
// which is a per-tag concern surfaced via AbCipTagDefinition.SafetyTag.
|
||||||
|
p.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||||
|
p.DefaultConnectionSize.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
|
||||||
|
p.DefaultCipPath.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultCipPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GuardLogix_safety_tag_surfaces_as_ViewOnly_in_discovery()
|
||||||
|
{
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("NormalTag", "ab://10.0.0.5/1,0", "N", AbCipDataType.DInt),
|
||||||
|
new AbCipTagDefinition("SafetyTag", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
|
||||||
|
Writable: true, SafetyTag: true),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
builder.Variables.Single(v => v.BrowseName == "NormalTag").Info.SecurityClass
|
||||||
|
.ShouldBe(SecurityClassification.Operate);
|
||||||
|
builder.Variables.Single(v => v.BrowseName == "SafetyTag").Info.SecurityClass
|
||||||
|
.ShouldBe(SecurityClassification.ViewOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GuardLogix_safety_tag_writes_rejected_even_when_Writable_is_true()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("SafetySet", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
|
||||||
|
Writable: true, SafetyTag: true),
|
||||||
|
],
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("SafetySet", 42)], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ForFamily dispatch ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
|
||||||
|
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
|
||||||
|
[InlineData(AbCipPlcFamily.Micro800, "micro800")]
|
||||||
|
[InlineData(AbCipPlcFamily.GuardLogix, "controllogix")]
|
||||||
|
public void ForFamily_dispatches_to_correct_profile(AbCipPlcFamily family, string expectedAttribute)
|
||||||
|
{
|
||||||
|
AbCipPlcFamilyProfile.ForFamily(family).LibplctagPlcAttribute.ShouldBe(expectedAttribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||||
|
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||||
|
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||||
|
{ Folders.Add((browseName, displayName)); return this; }
|
||||||
|
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||||
|
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||||
|
|
||||||
|
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||||
|
|
||||||
|
private sealed class Handle(string fullRef) : IVariableHandle
|
||||||
|
{
|
||||||
|
public string FullReference => fullRef;
|
||||||
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||||
|
}
|
||||||
|
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user