From 60b8d6f2d0ec3437bd7c612aeb4893333494e79b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 17:18:51 -0400 Subject: [PATCH] =?UTF-8?q?AB=20CIP=20PR=209-12=20=E2=80=94=20Per-PLC-fami?= =?UTF-8?q?ly=20profile=20tests=20+=20GuardLogix=20safety-tag=20support.?= =?UTF-8?q?=20Consolidates=20PRs=209/10/11/12=20from=20the=20plan=20(Contr?= =?UTF-8?q?olLogix=20/=20CompactLogix=20/=20Micro800=20/=20GuardLogix=20in?= =?UTF-8?q?tegration=20suites)=20into=20a=20single=20PR=20because=20the=20?= =?UTF-8?q?per-family=20work=20that=20actually=20ships=20without=20a=20liv?= =?UTF-8?q?e=20ab=5Fserver=20binary=20is=20profile-metadata=20assertion=20?= =?UTF-8?q?+=20unit-level=20driver-option=20binding.=20Per-family=20integr?= =?UTF-8?q?ation=20tests=20that=20require=20a=20running=20simulator=20are?= =?UTF-8?q?=20deferred=20to=20the=20ab=5Fserver-CI=20follow-up=20already?= =?UTF-8?q?=20tracked=20from=20PR=203=20(download=20prebuilt=20Windows=20b?= =?UTF-8?q?inary=20as=20GitHub=20release=20asset).=20ControlLogix=20?= =?UTF-8?q?=E2=80=94=20baseline=20profile=20asserted=20(controllogix=20att?= =?UTF-8?q?ribute,=204002=20LFO=20ConnectionSize,=201,0=20default=20path,?= =?UTF-8?q?=20request-packing=20+=20connected-messaging,=204000B=20max=20f?= =?UTF-8?q?ragment).=20CompactLogix=20=E2=80=94=20narrower=20504=20Connect?= =?UTF-8?q?ionSize=20for=205069-L3x=20safety,=20500B=20max=20fragment,=20l?= =?UTF-8?q?ib=20attribute=20compactlogix=20which=20libplctag=20maps=20to?= =?UTF-8?q?=20the=20ControlLogix=20family=20internally=20but=20via=20our?= =?UTF-8?q?=20profile=20chain=20we=20surface=20it=20as=20a=20distinct=20kn?= =?UTF-8?q?ob=20so=20future=20quirk=20handling=20(5069=20narrow-window=20r?= =?UTF-8?q?egression=20cases)=20hangs=20off=20the=20compactlogix=20attribu?= =?UTF-8?q?te.=20Micro800=20=E2=80=94=20empty=20CIP=20path=20for=20no-back?= =?UTF-8?q?plane=20routing,=20488B=20ConnectionSize,=20484B=20fragment=20c?= =?UTF-8?q?ap,=20request=20packing=20+=20connected=20messaging=20both=20di?= =?UTF-8?q?sabled=20(most=20models=20reject=20Forward=5FOpen),=20micro800?= =?UTF-8?q?=20lib=20attribute.=20Test=20asserts=20the=20driver=20correctly?= =?UTF-8?q?=20parses=20an=20ab://192.168.1.20/=20host=20address=20with=20e?= =?UTF-8?q?mpty=20path=20+=20forwards=20the=20empty=20path=20through=20AbC?= =?UTF-8?q?ipTagCreateParams=20so=20libplctag=20sees=20the=20unconnected-o?= =?UTF-8?q?nly=20configuration.=20GuardLogix=20=E2=80=94=20wire=20protocol?= =?UTF-8?q?=20identical=20to=20ControlLogix=20(safety=20partition=20is=20a?= =?UTF-8?q?=20per-tag=20concern,=20not=20a=20wire-layer=20distinction)=20s?= =?UTF-8?q?o=20profile=20defaults=20match=20ControlLogix.=20New=20AbCipTag?= =?UTF-8?q?Definition.SafetyTag=20field=20=E2=80=94=20when=20true,=20the?= =?UTF-8?q?=20driver=20forces=20SecurityClassification.ViewOnly=20in=20dis?= =?UTF-8?q?covery=20regardless=20of=20the=20Writable=20flag,=20and=20IWrit?= =?UTF-8?q?able=20rejects=20the=20write=20upfront=20with=20BadNotWritable.?= =?UTF-8?q?=20Matches=20the=20Rockwell=20safety-partition=20isolation=20mo?= =?UTF-8?q?del=20where=20non-safety-task=20writes=20to=20safety=20tags=20w?= =?UTF-8?q?ould=20be=20rejected=20by=20the=20PLC=20anyway=20=E2=80=94=20su?= =?UTF-8?q?rfacing=20the=20intent=20at=20the=20driver=20surface=20prevents?= =?UTF-8?q?=20wasted=20wire=20round-trips=20+=20gives=20Admin=20UI=20users?= =?UTF-8?q?=20a=20correct=20ViewOnly=20rendering.=2014=20new=20unit=20test?= =?UTF-8?q?s=20in=20AbCipPlcFamilyTests=20covering=20=E2=80=94=20ControlLo?= =?UTF-8?q?gix=20profile=20defaults=20+=20correct=20profile=20selection=20?= =?UTF-8?q?at=20Initialize,=20CompactLogix=20narrower-than-ControlLogix=20?= =?UTF-8?q?ConnectionSize=20+=20fragment=20cap,=20Micro800=20empty=20path?= =?UTF-8?q?=20parses=20+=20SupportsConnectedMessaging=3Dfalse=20+=20Suppor?= =?UTF-8?q?tsRequestPacking=3Dfalse=20+=20read=20forwards=20empty=20path?= =?UTF-8?q?=20+=20micro800=20attribute=20through=20to=20libplctag,=20Guard?= =?UTF-8?q?Logix=20wire-protocol=20parity=20with=20ControlLogix,=20GuardLo?= =?UTF-8?q?gix=20safety=20tag=20surfaces=20as=20ViewOnly=20in=20discovery?= =?UTF-8?q?=20even=20when=20Writable=3Dtrue,=20GuardLogix=20safety-tag=20w?= =?UTF-8?q?rite=20rejected=20with=20BadNotWritable=20even=20when=20Writabl?= =?UTF-8?q?e=3Dtrue,=20ForFamily=20theory=20(4=20families=20=E2=86=92=20co?= =?UTF-8?q?rrect=20libplctag=20attribute).=20Total=20AbCip=20unit=20tests?= =?UTF-8?q?=20now=20161/161=20passing=20(+14=20from=20PR=208's=20147).=20M?= =?UTF-8?q?odbus=20+=20other=20drivers=20untouched;=20full=20solution=20bu?= =?UTF-8?q?ilds=200=20errors.=20PR=2013=20(IAlarmSource=20via=20tag-projec?= =?UTF-8?q?ted=20ALMA/ALMD=20blocks)=20remains=20deferred=20per=20the=20pl?= =?UTF-8?q?an=20=E2=80=94=20feature-flagged=20pattern=20not=20needed=20bef?= =?UTF-8?q?ore=20go-live.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AbCipDriver.cs | 4 +- .../AbCipDriverOptions.cs | 9 +- .../AbCipPlcFamilyTests.cs | 209 ++++++++++++++++++ 3 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs 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) { } } + } +} -- 2.49.1