Merge pull request (#116) - AbCip family profiles
This commit was merged in pull request #116.
This commit is contained in:
@@ -316,7 +316,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!def.Writable)
|
||||
if (!def.Writable || def.SafetyTag)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
@@ -521,7 +521,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: tag.Writable
|
||||
SecurityClass: (tag.Writable && !tag.SafetyTag)
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
|
||||
@@ -59,6 +59,12 @@ public sealed record AbCipDeviceOptions(
|
||||
/// 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
|
||||
/// 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(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
@@ -66,7 +72,8 @@ public sealed record AbCipTagDefinition(
|
||||
AbCipDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false,
|
||||
IReadOnlyList<AbCipStructureMember>? Members = null);
|
||||
IReadOnlyList<AbCipStructureMember>? Members = null,
|
||||
bool SafetyTag = false);
|
||||
|
||||
/// <summary>
|
||||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
||||
|
||||
@@ -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