Compare commits

...

4 Commits

Author SHA1 Message Date
Joseph Doherty
60b8d6f2d0 AB CIP PR 9-12 — Per-PLC-family profile tests + GuardLogix safety-tag support. Consolidates PRs 9/10/11/12 from the plan (ControlLogix / CompactLogix / Micro800 / GuardLogix integration suites) into a single PR because the per-family work that actually ships without a live ab_server binary is profile-metadata assertion + unit-level driver-option binding. Per-family integration tests that require a running simulator are deferred to the ab_server-CI follow-up already tracked from PR 3 (download prebuilt Windows binary as GitHub release asset). ControlLogix — baseline profile asserted (controllogix attribute, 4002 LFO ConnectionSize, 1,0 default path, request-packing + connected-messaging, 4000B max fragment). CompactLogix — narrower 504 ConnectionSize for 5069-L3x safety, 500B max fragment, lib attribute compactlogix which libplctag maps to the ControlLogix family internally but via our profile chain we surface it as a distinct knob so future quirk handling (5069 narrow-window regression cases) hangs off the compactlogix attribute. Micro800 — empty CIP path for no-backplane routing, 488B ConnectionSize, 484B fragment cap, request packing + connected messaging both disabled (most models reject Forward_Open), micro800 lib attribute. Test asserts the driver correctly parses an ab://192.168.1.20/ host address with empty path + forwards the empty path through AbCipTagCreateParams so libplctag sees the unconnected-only configuration. GuardLogix — wire protocol identical to ControlLogix (safety partition is a per-tag concern, not a wire-layer distinction) so profile defaults match ControlLogix. New AbCipTagDefinition.SafetyTag field — when true, the driver forces SecurityClassification.ViewOnly in discovery regardless of the Writable flag, and IWritable rejects the write upfront with BadNotWritable. Matches the Rockwell safety-partition isolation model where non-safety-task writes to safety tags would be rejected by the PLC anyway — surfacing the intent at the driver surface prevents wasted wire round-trips + gives Admin UI users a correct ViewOnly rendering. 14 new unit tests in AbCipPlcFamilyTests covering — ControlLogix profile defaults + correct profile selection at Initialize, CompactLogix narrower-than-ControlLogix ConnectionSize + fragment cap, Micro800 empty path parses + SupportsConnectedMessaging=false + SupportsRequestPacking=false + read forwards empty path + micro800 attribute through to libplctag, GuardLogix wire-protocol parity with ControlLogix, GuardLogix safety tag surfaces as ViewOnly in discovery even when Writable=true, GuardLogix safety-tag write rejected with BadNotWritable even when Writable=true, ForFamily theory (4 families → correct libplctag attribute). Total AbCip unit tests now 161/161 passing (+14 from PR 8's 147). Modbus + other drivers untouched; full solution builds 0 errors. PR 13 (IAlarmSource via tag-projected ALMA/ALMD blocks) remains deferred per the plan — feature-flagged pattern not needed before go-live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:18:51 -04:00
30f971599e Merge pull request (#115) - AbCip host probe + resolver 2026-04-19 17:16:59 -04:00
Joseph Doherty
ac14ba9664 AB CIP PR 8 — IHostConnectivityProbe + IPerCallHostResolver. Per-device probe loop — when AbCipProbeOptions.Enabled + ProbeTagPath are configured, InitializeAsync kicks off one probe task per device that periodically reads the probe tag (lazy-init on first attempt, re-init on wire failure so destroyed native handles get recreated rather than silently staying broken), transitions Running on status==0 or Stopped on non-zero status / exception, raises OnHostStatusChanged with the device HostAddress as the host-name key. TransitionDeviceState guards against spurious same-state events under a per-device lock. ShutdownAsync cancels + disposes each probe's CTS + its captured runtime. DeviceState record gains ProbeLock / HostState / HostStateChangedUtc / ProbeCts / ProbeInitialized fields. IHostConnectivityProbe.GetHostStatuses returns one HostConnectivityStatus per device with the current state + last-change timestamp, surfaced to Admin /hosts per plan decision #144. IPerCallHostResolver.ResolveHost maps a tag full-reference to its DeviceHostAddress via the _tagsByName dict populated at Initialize time, which means UDT member full-references (Motor1.Speed synthesised by PR 6) resolve to the parent UDT's device without extra bookkeeping. Unknown references fall back to the first configured device's host address (invoker handles the actual mislookup at read time as BadNodeIdUnknown), and when no devices are configured resolver returns DriverInstanceId so the single-host fallback pipeline still works. Matches the plan decision #144 contract — Phase 6.1 resilience keys its bulkhead + breaker on (DriverInstanceId, ResolvedHostName) so a dead PLC trips only its own breaker, healthy siblings keep serving. 10 new unit tests in AbCipHostProbeTests covering GetHostStatuses returning one entry per device, probe success transitioning Unknown → Running, probe exception transitioning to Stopped, Enabled=false skipping the loop (no events + state stays Unknown), null ProbeTagPath skipping the loop, multi-device independent probe behavior (one Running + one Stopped simultaneously), ResolveHost for known tags returning the declared DeviceHostAddress, ResolveHost for unknown ref falling back to first device, ResolveHost falling back to DriverInstanceId when no devices, ResolveHost for UDT member walking to the synthesised member definition. Total AbCip unit tests now 147/147 passing (+10 from PR 7's 137). Full solution builds 0 errors; Modbus + other drivers untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:15:10 -04:00
5978ea002d Merge pull request (#114) - AbCip ISubscribable 2026-04-19 17:13:41 -04:00
4 changed files with 555 additions and 7 deletions

View File

@@ -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
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
/// </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 string _driverInstanceId;
@@ -33,6 +34,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null,
@@ -70,9 +72,6 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
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;
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);
}
catch (Exception ex)
@@ -109,7 +119,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
await _poll.DisposeAsync().ConfigureAwait(false);
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.DisposeHandles();
}
_devices.Clear();
_tagsByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
@@ -127,6 +142,90 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
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 ----
/// <summary>
@@ -217,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;
@@ -422,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,
@@ -457,6 +556,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public AbCipDeviceOptions Options { get; } = options;
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; } =
new(StringComparer.OrdinalIgnoreCase);

View File

@@ -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>,

View File

@@ -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);
}
}

View File

@@ -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) { } }
}
}