Compare commits
10 Commits
abcip-pr7-
...
ablegacy-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
400fc6242c | ||
| 4438fdd7b1 | |||
|
|
b2424a0616 | ||
| 59c99190c6 | |||
|
|
fc575e8dae | ||
| 70f5f2cad1 | |||
|
|
60b8d6f2d0 | ||
| 30f971599e | |||
|
|
ac14ba9664 | ||
| 5978ea002d |
@@ -11,6 +11,7 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
@@ -31,6 +32,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
102
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs
Normal file
102
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed PCCC file-based address: file letter + file number + word number, optionally a
|
||||
/// sub-element (<c>.ACC</c> on a timer) or bit index (<c>/0</c> on a bit file).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Logix symbolic tags are parsed elsewhere (<see cref="AbLegacy"/> is for SLC / PLC-5 /
|
||||
/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number).</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>N7:0</c> — integer file 7, word 0 (signed 16-bit).</item>
|
||||
/// <item><c>N7:5</c> — integer file 7, word 5.</item>
|
||||
/// <item><c>F8:0</c> — float file 8, word 0 (32-bit IEEE754).</item>
|
||||
/// <item><c>B3:0/0</c> — bit file 3, word 0, bit 0.</item>
|
||||
/// <item><c>ST9:0</c> — string file 9, string 0 (82-byte fixed-length + length word).</item>
|
||||
/// <item><c>T4:0.ACC</c> — timer file 4, timer 0, accumulator sub-element.</item>
|
||||
/// <item><c>C5:0.PRE</c> — counter file 5, counter 0, preset sub-element.</item>
|
||||
/// <item><c>I:0/0</c> — input file, slot 0, bit 0 (no file-number for I/O).</item>
|
||||
/// <item><c>O:1/2</c> — output file, slot 1, bit 2.</item>
|
||||
/// <item><c>S:1</c> — status file, word 1.</item>
|
||||
/// <item><c>L9:0</c> — long-integer file (SLC 5/05+, 32-bit).</item>
|
||||
/// </list>
|
||||
/// <para>Pass the original string straight through to libplctag's <c>name=...</c> attribute —
|
||||
/// the PLC-side decoder handles the format. This parser only validates the shape + surfaces
|
||||
/// the structural pieces for driver-side routing (e.g. deciding whether a tag needs
|
||||
/// bit-level read-modify-write).</para>
|
||||
/// </remarks>
|
||||
public sealed record AbLegacyAddress(
|
||||
string FileLetter,
|
||||
int? FileNumber,
|
||||
int WordNumber,
|
||||
int? BitIndex,
|
||||
string? SubElement)
|
||||
{
|
||||
public string ToLibplctagName()
|
||||
{
|
||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||
var wordPart = $"{file}:{WordNumber}";
|
||||
if (SubElement is not null) wordPart += $".{SubElement}";
|
||||
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||
return wordPart;
|
||||
}
|
||||
|
||||
public static AbLegacyAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
// BitIndex: trailing /N
|
||||
int? bitIndex = null;
|
||||
var slashIdx = src.IndexOf('/');
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0 || bit > 31) return null;
|
||||
bitIndex = bit;
|
||||
src = src[..slashIdx];
|
||||
}
|
||||
|
||||
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
|
||||
string? subElement = null;
|
||||
var dotIdx = src.LastIndexOf('.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
var candidate = src[(dotIdx + 1)..];
|
||||
if (candidate.Length > 0 && candidate.All(char.IsLetter))
|
||||
{
|
||||
subElement = candidate.ToUpperInvariant();
|
||||
src = src[..dotIdx];
|
||||
}
|
||||
}
|
||||
|
||||
var colonIdx = src.IndexOf(':');
|
||||
if (colonIdx <= 0) return null;
|
||||
var filePart = src[..colonIdx];
|
||||
var wordPart = src[(colonIdx + 1)..];
|
||||
if (!int.TryParse(wordPart, out var word) || word < 0) return null;
|
||||
|
||||
// File letter + optional file number (single letter for I/O/S, letter+number otherwise).
|
||||
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
||||
var letterEnd = 1;
|
||||
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
||||
|
||||
var letter = filePart[..letterEnd].ToUpperInvariant();
|
||||
int? fileNumber = null;
|
||||
if (letterEnd < filePart.Length)
|
||||
{
|
||||
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
|
||||
fileNumber = fn;
|
||||
}
|
||||
|
||||
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
|
||||
if (!IsKnownFileLetter(letter)) return null;
|
||||
|
||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
|
||||
}
|
||||
|
||||
private static bool IsKnownFileLetter(string letter) => letter switch
|
||||
{
|
||||
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
45
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs
Normal file
45
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// PCCC data types that map onto SLC / MicroLogix / PLC-5 files. Narrower than Logix — no
|
||||
/// symbolic UDTs; every type is file-typed and fixed-width.
|
||||
/// </summary>
|
||||
public enum AbLegacyDataType
|
||||
{
|
||||
/// <summary>B-file single bit (<c>B3:0/0</c>) or bit-within-N-file (<c>N7:0/3</c>).</summary>
|
||||
Bit,
|
||||
/// <summary>N-file integer (signed 16-bit).</summary>
|
||||
Int,
|
||||
/// <summary>L-file long integer — SLC 5/05+ only (signed 32-bit).</summary>
|
||||
Long,
|
||||
/// <summary>F-file float (32-bit IEEE-754).</summary>
|
||||
Float,
|
||||
/// <summary>A-file analog integer — some older hardware (signed 16-bit, semantically like N).</summary>
|
||||
AnalogInt,
|
||||
/// <summary>ST-file string (82-byte fixed-length + length word header).</summary>
|
||||
String,
|
||||
/// <summary>Timer sub-element — caller addresses <c>.ACC</c>, <c>.PRE</c>, <c>.EN</c>, <c>.DN</c>, <c>.TT</c>.</summary>
|
||||
TimerElement,
|
||||
/// <summary>Counter sub-element — caller addresses <c>.ACC</c>, <c>.PRE</c>, <c>.CU</c>, <c>.CD</c>, <c>.DN</c>.</summary>
|
||||
CounterElement,
|
||||
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
|
||||
ControlElement,
|
||||
}
|
||||
|
||||
/// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
||||
public static class AbLegacyDataTypeExtensions
|
||||
{
|
||||
public static DriverDataType ToDriverDataType(this AbLegacyDataType t) => t switch
|
||||
{
|
||||
AbLegacyDataType.Bit => DriverDataType.Boolean,
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => DriverDataType.Int32,
|
||||
AbLegacyDataType.Long => DriverDataType.Int32, // matches Modbus/AbCip 64→32 gap
|
||||
AbLegacyDataType.Float => DriverDataType.Float32,
|
||||
AbLegacyDataType.String => DriverDataType.String,
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
389
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
Normal file
389
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
Normal file
@@ -0,0 +1,389 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// AB Legacy / PCCC driver — SLC 500, MicroLogix, PLC-5, LogixPccc. Implements
|
||||
/// <see cref="IDriver"/> only at PR 1 time; read / write / discovery / subscribe / probe /
|
||||
/// host-resolver capabilities ship in PRs 2 and 3.
|
||||
/// </summary>
|
||||
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbLegacyDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbLegacyTagFactory _tagFactory;
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId,
|
||||
IAbLegacyTagFactory? tagFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
public string DriverType => "AbLegacy";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
|
||||
var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||||
}
|
||||
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
||||
|
||||
// Probe loops — one per device when enabled + probe address configured.
|
||||
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress))
|
||||
{
|
||||
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)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.DisposeRuntimes();
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
internal int DeviceCount => _devices.Count;
|
||||
internal DeviceState? GetDeviceState(string hostAddress) =>
|
||||
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbLegacyStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
|
||||
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbLegacyStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!def.Writable)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
results[i] = new WriteResult(status == 0
|
||||
? AbLegacyStatusMapper.Good
|
||||
: AbLegacyStatusMapper.MapLibplctagStatus(status));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var root = builder.Folder("AbLegacy", "AbLegacy");
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var label = device.DeviceName ?? device.HostAddress;
|
||||
var deviceFolder = root.Folder(device.HostAddress, label);
|
||||
var tagsForDevice = _options.Tags.Where(t =>
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
{
|
||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: tag.Writable
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
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 AbLegacyTagCreateParams(
|
||||
Gateway: state.ParsedAddress.Gateway,
|
||||
Port: state.ParsedAddress.Port,
|
||||
CipPath: state.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||
TagName: _options.Probe.ProbeAddress!,
|
||||
Timeout: _options.Probe.Timeout);
|
||||
|
||||
IAbLegacyTagRuntime? probeRuntime = null;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
probeRuntime ??= _tagFactory.Create(probeParams);
|
||||
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
|
||||
{
|
||||
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 ----
|
||||
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
return def.DeviceHostAddress;
|
||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||
}
|
||||
|
||||
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
||||
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||
|
||||
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsed.ToLibplctagName(),
|
||||
Timeout: _options.Timeout));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.Runtimes[def.Name] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
internal sealed class DeviceState(
|
||||
AbLegacyHostAddress parsedAddress,
|
||||
AbLegacyDeviceOptions options,
|
||||
AbLegacyPlcFamilyProfile profile)
|
||||
{
|
||||
public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress;
|
||||
public AbLegacyDeviceOptions Options { get; } = options;
|
||||
public AbLegacyPlcFamilyProfile Profile { get; } = profile;
|
||||
public Dictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
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 void DisposeRuntimes()
|
||||
{
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
Runtimes.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// AB Legacy (PCCC) driver configuration. One instance supports N devices (SLC 500 /
|
||||
/// MicroLogix / PLC-5 / LogixPccc). Per plan decision #41 AbLegacy ships separately from
|
||||
/// AbCip because PCCC's file-based addressing (<c>N7:0</c>) and Logix's symbolic addressing
|
||||
/// (<c>Motor1.Speed</c>) pull the abstraction in different directions.
|
||||
/// </summary>
|
||||
public sealed class AbLegacyDriverOptions
|
||||
{
|
||||
public IReadOnlyList<AbLegacyDeviceOptions> Devices { get; init; } = [];
|
||||
public IReadOnlyList<AbLegacyTagDefinition> Tags { get; init; } = [];
|
||||
public AbLegacyProbeOptions Probe { get; init; } = new();
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
public sealed record AbLegacyDeviceOptions(
|
||||
string HostAddress,
|
||||
AbLegacyPlcFamily PlcFamily = AbLegacyPlcFamily.Slc500,
|
||||
string? DeviceName = null);
|
||||
|
||||
/// <summary>
|
||||
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
||||
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>.
|
||||
/// </summary>
|
||||
public sealed record AbLegacyTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string Address,
|
||||
AbLegacyDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
|
||||
public sealed class AbLegacyProbeOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Probe address — defaults to <c>S:0</c> (status file, first word) when null.</summary>
|
||||
public string? ProbeAddress { get; init; } = "S:0";
|
||||
}
|
||||
53
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs
Normal file
53
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed <c>ab://gateway[:port]/cip-path</c> host-address string for AB Legacy devices.
|
||||
/// Same format as AbCip — PCCC-over-EIP uses the same gateway + optional routing path as
|
||||
/// the CIP family (a PLC-5 bridged through a ControlLogix chassis takes the full CIP path;
|
||||
/// a direct-wired SLC 500 uses an empty path).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Parser duplicated from AbCipHostAddress rather than shared because the two drivers ship
|
||||
/// independently + a shared helper would force a reference between them. If a third AB
|
||||
/// driver appears, extract into Core.Abstractions.
|
||||
/// </remarks>
|
||||
public sealed record AbLegacyHostAddress(string Gateway, int Port, string CipPath)
|
||||
{
|
||||
public const int DefaultEipPort = 44818;
|
||||
|
||||
public override string ToString() => Port == DefaultEipPort
|
||||
? $"ab://{Gateway}/{CipPath}"
|
||||
: $"ab://{Gateway}:{Port}/{CipPath}";
|
||||
|
||||
public static AbLegacyHostAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
const string prefix = "ab://";
|
||||
if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var remainder = value[prefix.Length..];
|
||||
var slashIdx = remainder.IndexOf('/');
|
||||
if (slashIdx < 0) return null;
|
||||
|
||||
var authority = remainder[..slashIdx];
|
||||
var cipPath = remainder[(slashIdx + 1)..];
|
||||
if (string.IsNullOrEmpty(authority)) return null;
|
||||
|
||||
var port = DefaultEipPort;
|
||||
var colonIdx = authority.LastIndexOf(':');
|
||||
string gateway;
|
||||
if (colonIdx >= 0)
|
||||
{
|
||||
gateway = authority[..colonIdx];
|
||||
if (!int.TryParse(authority[(colonIdx + 1)..], out port) || port is <= 0 or > 65535)
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
gateway = authority;
|
||||
}
|
||||
if (string.IsNullOrEmpty(gateway)) return null;
|
||||
|
||||
return new AbLegacyHostAddress(gateway, port, cipPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Maps libplctag status codes + PCCC STS/EXT_STS bytes to OPC UA StatusCodes. Mirrors the
|
||||
/// AbCip mapper — PCCC errors roughly align with CIP general-status in shape but with a
|
||||
/// different byte vocabulary (PCCC STS nibble-low + EXT_STS on code 0x0F).
|
||||
/// </summary>
|
||||
public static class AbLegacyStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint GoodMoreData = 0x00A70000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
public const uint BadDeviceFailure = 0x80550000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map libplctag return/status codes. Same polarity as the AbCip mapper — 0 success,
|
||||
/// positive pending, negative error families.
|
||||
/// </summary>
|
||||
public static uint MapLibplctagStatus(int status)
|
||||
{
|
||||
if (status == 0) return Good;
|
||||
if (status > 0) return GoodMoreData;
|
||||
return status switch
|
||||
{
|
||||
-5 => BadTimeout,
|
||||
-7 => BadCommunicationError,
|
||||
-14 => BadNodeIdUnknown,
|
||||
-16 => BadNotWritable,
|
||||
-17 => BadOutOfRange,
|
||||
_ => BadCommunicationError,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a PCCC STS (status) byte. Common codes per AB PCCC reference:
|
||||
/// 0x00 = success, 0x10 = illegal command, 0x20 = bad address, 0x30 = protected,
|
||||
/// 0x40 = programmer busy, 0x50 = file locked, 0xF0 = extended status follows.
|
||||
/// </summary>
|
||||
public static uint MapPcccStatus(byte sts) => sts switch
|
||||
{
|
||||
0x00 => Good,
|
||||
0x10 => BadNotSupported,
|
||||
0x20 => BadNodeIdUnknown,
|
||||
0x30 => BadNotWritable,
|
||||
0x40 => BadDeviceFailure,
|
||||
0x50 => BadDeviceFailure,
|
||||
0xF0 => BadInternalError, // extended status not inspected at this layer
|
||||
_ => BadCommunicationError,
|
||||
};
|
||||
}
|
||||
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
Normal file
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-layer abstraction over a single PCCC tag. Mirrors <c>IAbCipTagRuntime</c>'s shape so
|
||||
/// the same test-fake pattern applies; the only meaningful difference is the protocol layer
|
||||
/// underneath (<c>ab_pccc</c> vs <c>ab_eip</c>).
|
||||
/// </summary>
|
||||
public interface IAbLegacyTagRuntime : IDisposable
|
||||
{
|
||||
Task InitializeAsync(CancellationToken cancellationToken);
|
||||
Task ReadAsync(CancellationToken cancellationToken);
|
||||
Task WriteAsync(CancellationToken cancellationToken);
|
||||
int GetStatus();
|
||||
object? DecodeValue(AbLegacyDataType type, int? bitIndex);
|
||||
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
|
||||
}
|
||||
|
||||
public interface IAbLegacyTagFactory
|
||||
{
|
||||
IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams);
|
||||
}
|
||||
|
||||
public sealed record AbLegacyTagCreateParams(
|
||||
string Gateway,
|
||||
int Port,
|
||||
string CipPath,
|
||||
string LibplctagPlcAttribute,
|
||||
string TagName,
|
||||
TimeSpan Timeout);
|
||||
@@ -0,0 +1,97 @@
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Default libplctag-backed <see cref="IAbLegacyTagRuntime"/>. Uses <c>ab_pccc</c> protocol
|
||||
/// on top of EtherNet/IP — libplctag's PCCC layer handles the file-letter + word + bit +
|
||||
/// sub-element decoding internally, so our wrapper just has to forward the atomic type to
|
||||
/// the right Get/Set call.
|
||||
/// </summary>
|
||||
internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
||||
{
|
||||
_tag = new Tag
|
||||
{
|
||||
Gateway = p.Gateway,
|
||||
Path = p.CipPath,
|
||||
PlcType = MapPlcType(p.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer
|
||||
Name = p.TagName,
|
||||
Timeout = p.Timeout,
|
||||
};
|
||||
}
|
||||
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
|
||||
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
|
||||
|
||||
public int GetStatus() => (int)_tag.GetStatus();
|
||||
|
||||
public object? DecodeValue(AbLegacyDataType type, int? bitIndex) => type switch
|
||||
{
|
||||
AbLegacyDataType.Bit => bitIndex is int bit
|
||||
? _tag.GetBit(bit)
|
||||
: _tag.GetInt8(0) != 0,
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)_tag.GetInt16(0),
|
||||
AbLegacyDataType.Long => _tag.GetInt32(0),
|
||||
AbLegacyDataType.Float => _tag.GetFloat32(0),
|
||||
AbLegacyDataType.String => _tag.GetString(0),
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => _tag.GetInt32(0),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbLegacyDataType.Bit:
|
||||
if (bitIndex is int)
|
||||
throw new NotSupportedException(
|
||||
"Bit-within-word writes require read-modify-write; tracked in task #181.");
|
||||
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
|
||||
break;
|
||||
case AbLegacyDataType.Int:
|
||||
case AbLegacyDataType.AnalogInt:
|
||||
_tag.SetInt16(0, Convert.ToInt16(value));
|
||||
break;
|
||||
case AbLegacyDataType.Long:
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
break;
|
||||
case AbLegacyDataType.Float:
|
||||
_tag.SetFloat32(0, Convert.ToSingle(value));
|
||||
break;
|
||||
case AbLegacyDataType.String:
|
||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
||||
break;
|
||||
case AbLegacyDataType.TimerElement:
|
||||
case AbLegacyDataType.CounterElement:
|
||||
case AbLegacyDataType.ControlElement:
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _tag.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
{
|
||||
"slc500" => PlcType.Slc500,
|
||||
"micrologix" => PlcType.MicroLogix,
|
||||
"plc5" => PlcType.Plc5,
|
||||
"logixpccc" => PlcType.LogixPccc,
|
||||
_ => PlcType.Slc500,
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class LibplctagLegacyTagFactory : IAbLegacyTagFactory
|
||||
{
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) =>
|
||||
new LibplctagLegacyTagRuntime(createParams);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
/// <summary>
|
||||
/// Per-family libplctag defaults for PCCC PLCs. SLC 500 / MicroLogix / PLC-5 / LogixPccc
|
||||
/// (Logix controller accessed via the PLC-5 compatibility layer — rare but real).
|
||||
/// </summary>
|
||||
public sealed record AbLegacyPlcFamilyProfile(
|
||||
string LibplctagPlcAttribute,
|
||||
string DefaultCipPath,
|
||||
int MaxTagBytes,
|
||||
bool SupportsStringFile,
|
||||
bool SupportsLongFile)
|
||||
{
|
||||
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
||||
{
|
||||
AbLegacyPlcFamily.Slc500 => Slc500,
|
||||
AbLegacyPlcFamily.MicroLogix => MicroLogix,
|
||||
AbLegacyPlcFamily.Plc5 => Plc5,
|
||||
AbLegacyPlcFamily.LogixPccc => LogixPccc,
|
||||
_ => Slc500,
|
||||
};
|
||||
|
||||
public static readonly AbLegacyPlcFamilyProfile Slc500 = new(
|
||||
LibplctagPlcAttribute: "slc500",
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
||||
SupportsStringFile: true, // ST file available SLC 5/04+
|
||||
SupportsLongFile: true); // L file available SLC 5/05+
|
||||
|
||||
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
|
||||
LibplctagPlcAttribute: "micrologix",
|
||||
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
||||
MaxTagBytes: 232,
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files
|
||||
|
||||
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
|
||||
LibplctagPlcAttribute: "plc5",
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: false); // PLC-5 predates L files
|
||||
|
||||
/// <summary>
|
||||
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
||||
/// Rare but real — some legacy HMI integrations address Logix controllers as if they were
|
||||
/// PLC-5 via the PCCC-passthrough mechanism.
|
||||
/// </summary>
|
||||
public static readonly AbLegacyPlcFamilyProfile LogixPccc = new(
|
||||
LibplctagPlcAttribute: "logixpccc",
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240,
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: true);
|
||||
}
|
||||
|
||||
/// <summary>Which PCCC PLC family the device is.</summary>
|
||||
public enum AbLegacyPlcFamily
|
||||
{
|
||||
Slc500,
|
||||
MicroLogix,
|
||||
Plc5,
|
||||
LogixPccc,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbLegacy</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.AbLegacy</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- libplctag — ab_pccc protocol for SLC 500 / MicroLogix / PLC-5 / LogixPccc.
|
||||
Decision #41 — AbLegacy split from AbCip since PCCC addressing (file-based N7:0) and
|
||||
Logix addressing (symbolic Motor1.Speed) pull the abstraction in incompatible directions. -->
|
||||
<PackageReference Include="libplctag" Version="1.5.2"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyAddressTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("N7:0", "N", 7, 0, null, null)]
|
||||
[InlineData("N7:15", "N", 7, 15, null, null)]
|
||||
[InlineData("F8:5", "F", 8, 5, null, null)]
|
||||
[InlineData("B3:0/0", "B", 3, 0, 0, null)]
|
||||
[InlineData("B3:2/7", "B", 3, 2, 7, null)]
|
||||
[InlineData("ST9:0", "ST", 9, 0, null, null)]
|
||||
[InlineData("L9:3", "L", 9, 3, null, null)]
|
||||
[InlineData("I:0/0", "I", null, 0, 0, null)]
|
||||
[InlineData("O:1/2", "O", null, 1, 2, null)]
|
||||
[InlineData("S:1", "S", null, 1, null, null)]
|
||||
[InlineData("T4:0.ACC", "T", 4, 0, null, "ACC")]
|
||||
[InlineData("T4:0.PRE", "T", 4, 0, null, "PRE")]
|
||||
[InlineData("C5:2.CU", "C", 5, 2, null, "CU")]
|
||||
[InlineData("R6:0.LEN", "R", 6, 0, null, "LEN")]
|
||||
[InlineData("N7:0/3", "N", 7, 0, 3, null)]
|
||||
public void TryParse_accepts_valid_pccc_addresses(string input, string letter, int? file, int word, int? bit, string? sub)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe(letter);
|
||||
a.FileNumber.ShouldBe(file);
|
||||
a.WordNumber.ShouldBe(word);
|
||||
a.BitIndex.ShouldBe(bit);
|
||||
a.SubElement.ShouldBe(sub);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("N7")] // missing :word
|
||||
[InlineData(":0")] // missing file
|
||||
[InlineData("X7:0")] // unknown file letter
|
||||
[InlineData("N7:-1")] // negative word
|
||||
[InlineData("N7:abc")] // non-numeric word
|
||||
[InlineData("N7:0/-1")] // negative bit
|
||||
[InlineData("N7:0/32")] // bit out of range
|
||||
[InlineData("Nabc:0")] // non-numeric file number
|
||||
public void TryParse_rejects_invalid_forms(string? input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("N7:0")]
|
||||
[InlineData("F8:5")]
|
||||
[InlineData("B3:0/0")]
|
||||
[InlineData("ST9:0")]
|
||||
[InlineData("T4:0.ACC")]
|
||||
[InlineData("I:0/0")]
|
||||
[InlineData("S:1")]
|
||||
public void ToLibplctagName_roundtrips(string input)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyCapabilityTests
|
||||
{
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_pre_declared_tags_under_device_folder()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Press-SLC-1")],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Speed", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Temperature", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float, Writable: false),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "AbLegacy");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Press-SLC-1");
|
||||
builder.Variables.Count.ShouldBe(2);
|
||||
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_initial_poll_raises_OnDataChange()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = 42 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.First().Snapshot.Value.ShouldBe(42);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_halts_polling()
|
||||
{
|
||||
var tagRef = new FakeAbLegacyTag(
|
||||
new AbLegacyTagCreateParams("10.0.0.5", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2))) { Value = 1 };
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = _ => tagRef };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
var afterUnsub = events.Count;
|
||||
tagRef.Value = 999;
|
||||
await Task.Delay(300);
|
||||
events.Count.ShouldBe(afterUnsub);
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_returns_one_per_device()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetHostStatuses().Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Running_on_successful_read()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) };
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0",
|
||||
},
|
||||
}, "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));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Stopped_on_read_failure()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true } };
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0",
|
||||
},
|
||||
}, "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_ProbeAddress_is_null()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = true, ProbeAddress = null },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(200);
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", "ab://10.0.0.6/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { 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()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("missing").ShouldBe("ab://10.0.0.5/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("anything").ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
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) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyDriverTests
|
||||
{
|
||||
[Fact]
|
||||
public void DriverType_is_AbLegacy()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-1");
|
||||
drv.DriverType.ShouldBe("AbLegacy");
|
||||
drv.DriverInstanceId.ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_with_devices_assigns_family_profiles()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.6/", AbLegacyPlcFamily.MicroLogix),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.7/1,0", AbLegacyPlcFamily.Plc5),
|
||||
],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(3);
|
||||
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.Slc500);
|
||||
drv.GetDeviceState("ab://10.0.0.6/")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.MicroLogix);
|
||||
drv.GetDeviceState("ab://10.0.0.7/1,0")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.Plc5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_with_malformed_host_address_faults()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("not-a-valid-address")],
|
||||
}, "drv-1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_clears_devices()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Family_profiles_expose_expected_defaults()
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.Slc500.LibplctagPlcAttribute.ShouldBe("slc500");
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsLongFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.DefaultCipPath.ShouldBe("1,0");
|
||||
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.DefaultCipPath.ShouldBe("");
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsLongFile.ShouldBeFalse();
|
||||
|
||||
AbLegacyPlcFamilyProfile.Plc5.LibplctagPlcAttribute.ShouldBe("plc5");
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsLongFile.ShouldBeFalse();
|
||||
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.LibplctagPlcAttribute.ShouldBe("logixpccc");
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsLongFile.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "slc500")]
|
||||
[InlineData(AbLegacyPlcFamily.MicroLogix, "micrologix")]
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, "plc5")]
|
||||
[InlineData(AbLegacyPlcFamily.LogixPccc, "logixpccc")]
|
||||
public void ForFamily_dispatches_correctly(AbLegacyPlcFamily family, string expectedAttribute)
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.ForFamily(family).LibplctagPlcAttribute.ShouldBe(expectedAttribute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataType_mapping_covers_atomic_pccc_types()
|
||||
{
|
||||
AbLegacyDataType.Bit.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
AbLegacyDataType.Int.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataType.Long.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataType.Float.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
AbLegacyDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
AbLegacyDataType.TimerElement.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyHostAndStatusTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ab://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")]
|
||||
[InlineData("ab://10.0.0.5/", "10.0.0.5", 44818, "")]
|
||||
[InlineData("ab://10.0.0.5:2222/1,0", "10.0.0.5", 2222, "1,0")]
|
||||
[InlineData("ab://plc-slc.factory/1,2", "plc-slc.factory", 44818, "1,2")]
|
||||
public void HostAddress_parses_valid(string input, string gateway, int port, string path)
|
||||
{
|
||||
var parsed = AbLegacyHostAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Gateway.ShouldBe(gateway);
|
||||
parsed.Port.ShouldBe(port);
|
||||
parsed.CipPath.ShouldBe(path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("http://10.0.0.5/1,0")]
|
||||
[InlineData("ab://10.0.0.5")]
|
||||
[InlineData("ab:///1,0")]
|
||||
[InlineData("ab://10.0.0.5:0/1,0")]
|
||||
public void HostAddress_rejects_invalid(string? input)
|
||||
{
|
||||
AbLegacyHostAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostAddress_ToString_canonicalises()
|
||||
{
|
||||
new AbLegacyHostAddress("10.0.0.5", 44818, "1,0").ToString().ShouldBe("ab://10.0.0.5/1,0");
|
||||
new AbLegacyHostAddress("10.0.0.5", 2222, "1,0").ToString().ShouldBe("ab://10.0.0.5:2222/1,0");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, AbLegacyStatusMapper.Good)]
|
||||
[InlineData((byte)0x10, AbLegacyStatusMapper.BadNotSupported)]
|
||||
[InlineData((byte)0x20, AbLegacyStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x30, AbLegacyStatusMapper.BadNotWritable)]
|
||||
[InlineData((byte)0x40, AbLegacyStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0x50, AbLegacyStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0xF0, AbLegacyStatusMapper.BadInternalError)]
|
||||
[InlineData((byte)0xFF, AbLegacyStatusMapper.BadCommunicationError)]
|
||||
public void PcccStatus_maps_known_codes(byte sts, uint expected)
|
||||
{
|
||||
AbLegacyStatusMapper.MapPcccStatus(sts).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, AbLegacyStatusMapper.Good)]
|
||||
[InlineData(1, AbLegacyStatusMapper.GoodMoreData)]
|
||||
[InlineData(-5, AbLegacyStatusMapper.BadTimeout)]
|
||||
[InlineData(-7, AbLegacyStatusMapper.BadCommunicationError)]
|
||||
[InlineData(-14, AbLegacyStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(-16, AbLegacyStatusMapper.BadNotWritable)]
|
||||
[InlineData(-17, AbLegacyStatusMapper.BadOutOfRange)]
|
||||
public void LibplctagStatus_maps_known_codes(int status, uint expected)
|
||||
{
|
||||
AbLegacyStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyReadWriteTests
|
||||
{
|
||||
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(params AbLegacyTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- Read ----
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_N_file_read_returns_Good_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 42 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(42);
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_reuses_runtime()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonZero_libplctag_status_maps_via_AbLegacyStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Status = -14 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_exception_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batched_reads_preserve_order()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float),
|
||||
new AbLegacyTagDefinition("C", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => p.TagName switch
|
||||
{
|
||||
"N7:0" => new FakeAbLegacyTag(p) { Value = 1 },
|
||||
"F8:0" => new FakeAbLegacyTag(p) { Value = 3.14f },
|
||||
_ => new FakeAbLegacyTag(p) { Value = "hello" },
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
snapshots[0].Value.ShouldBe(1);
|
||||
snapshots[1].Value.ShouldBe(3.14f);
|
||||
snapshots[2].Value.ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_TagCreateParams_composed_from_device_and_profile()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:5", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var p = factory.Tags["N7:5"].CreationParams;
|
||||
p.Gateway.ShouldBe("10.0.0.5");
|
||||
p.Port.ShouldBe(44818);
|
||||
p.CipPath.ShouldBe("1,0");
|
||||
p.LibplctagPlcAttribute.ShouldBe("slc500");
|
||||
p.TagName.ShouldBe("N7:5");
|
||||
}
|
||||
|
||||
// ---- Write ----
|
||||
|
||||
[Fact]
|
||||
public async Task Non_writable_tag_rejects_with_BadNotWritable()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new AbLegacyTagDefinition("RO", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("RO", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_N_file_write_encodes_and_flushes()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("X", 123)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags["N7:0"].Value.ShouldBe(123);
|
||||
factory.Tags["N7:0"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_within_word_write_rejected_as_BadNotSupported()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new RmwThrowingFake(p) };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("Bit3", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Bit3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_exception_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnWrite = true };
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("X", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_write_preserves_order_across_outcomes()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int, Writable: false),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("Unknown", 3),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable);
|
||||
results[2].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p)
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["X"], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_disposes_runtimes()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 };
|
||||
|
||||
await drv.ReadAsync(["A"], CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
factory.Tags["N7:0"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private sealed class RmwThrowingFake(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
if (type == AbLegacyDataType.Bit && bitIndex is not null)
|
||||
throw new NotSupportedException("bit-within-word RMW deferred");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
{
|
||||
public AbLegacyTagCreateParams CreationParams { get; }
|
||||
public object? Value { get; set; }
|
||||
public int Status { get; set; }
|
||||
public bool ThrowOnInitialize { get; set; }
|
||||
public bool ThrowOnRead { get; set; }
|
||||
public bool ThrowOnWrite { get; set; }
|
||||
public Exception? Exception { get; set; }
|
||||
public int InitializeCount { get; private set; }
|
||||
public int ReadCount { get; private set; }
|
||||
public int WriteCount { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public FakeAbLegacyTag(AbLegacyTagCreateParams p) => CreationParams = p;
|
||||
|
||||
public virtual Task InitializeAsync(CancellationToken ct)
|
||||
{
|
||||
InitializeCount++;
|
||||
if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task ReadAsync(CancellationToken ct)
|
||||
{
|
||||
ReadCount++;
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task WriteAsync(CancellationToken ct)
|
||||
{
|
||||
WriteCount++;
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual int GetStatus() => Status;
|
||||
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value;
|
||||
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
internal sealed class FakeAbLegacyTagFactory : IAbLegacyTagFactory
|
||||
{
|
||||
public Dictionary<string, FakeAbLegacyTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Func<AbLegacyTagCreateParams, FakeAbLegacyTag>? Customise { get; set; }
|
||||
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p)
|
||||
{
|
||||
var fake = Customise?.Invoke(p) ?? new FakeAbLegacyTag(p);
|
||||
Tags[p.TagName] = fake;
|
||||
return fake;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user