diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 79c7e7c..33376fa 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -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,
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
index 552660b..735fca2 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
@@ -59,6 +59,12 @@ public sealed record AbCipDeviceOptions(
/// member (member TagPath = {tag.TagPath}.{member.Name}). When null on a Structure
/// tag, the driver treats it as a black-box and relies on downstream configuration to address
/// members individually via dotted syntax. Ignored for atomic types.
+/// GuardLogix safety-partition tag hint. When true, the driver
+/// forces SecurityClassification.ViewOnly on discovery regardless of
+/// — 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.
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? Members = null);
+ IReadOnlyList? Members = null,
+ bool SafetyTag = false);
///
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. Speed,
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs
new file mode 100644
index 0000000..5c3068c
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs
@@ -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) { } }
+ }
+}