AB Legacy PR 1 — Scaffolding + Core (AbLegacyDriver + PCCC address parser). New Driver.AbLegacy project with the libplctag 1.5.2 reference + the same Core.Abstractions-only project shape AbCip uses. AbLegacyHostAddress duplicates the ab://gateway[:port]/cip-path parser from AbCip since PCCC-over-EIP uses the same gateway routing convention (SLC 500 direct-wired with empty path, PLC-5 bridged through a ControlLogix chassis with full CIP path). Parser is 30 lines; copy was cheaper than introducing a shared Ab* project just to avoid duplication. AbLegacyAddress handles PCCC file addressing — file-letter + optional file-number + colon + word-number + optional sub-element (.ACC / .PRE / .EN / .DN / .CU / .CD / .LEN / .POS / .ER) + optional /bit-index. Handles the full shape variety — N7:0 (integer file 7 word 0), F8:5 (float file 8 word 5), B3:0/0 (bit file 3 word 0 bit 0), ST9:0 (string file 9 string 0), L9:3 (long file SLC 5/05+), T4:0.ACC (timer accumulator), C5:2.CU (counter count-up bit), R6:0.LEN (control length), I:0/0 (input file bit — no file number for I/O/S), O:1/2 (output file bit), S:1 (status file word), N7:0/3 (bit within integer file). Validates file letters against the canonical SLC/ML/PLC-5 set (N/F/B/L/ST/T/C/R/I/O/S/A). ToLibplctagName roundtrips so the parsed value can be handed straight to libplctag's name= attribute. AbLegacyDataType — Bit / Int (N-file, 16-bit signed) / Long (L-file, 32-bit, SLC 5/05+ only) / Float (F-file, 32-bit IEEE-754) / AnalogInt (A-file) / String (ST-file, 82-byte fixed + length word) / TimerElement / CounterElement / ControlElement. ToDriverDataType widens Long to Int32 matching the Modbus/AbCip Int64-gap convention. AbLegacyStatusMapper shares the OPC UA status constants with AbCip (same numeric values, different namespace). MapLibplctagStatus mirrors AbCip — 0 success, positive pending, negative error code families. MapPcccStatus handles PCCC STS bytes — 0x00 success, 0x10 illegal command, 0x20 bad address, 0x30 protected, 0x40/0x50 busy, 0xF0 extended status. AbLegacyDriverOptions + AbLegacyDeviceOptions + AbLegacyTagDefinition + AbLegacyProbeOptions mirror AbCip shapes — one instance supports N devices via Devices list, Tags list references devices by HostAddress cross-key, Probe uses S:0 by default as the cheap probe address. AbLegacyPlcFamilyProfile for four families — Slc500 (slc500 attribute, 1,0 default path, supports L + ST files, 240B max PCCC packet), MicroLogix (micrologix attribute, empty path for direct EIP, supports ST but not L), Plc5 (plc5 attribute, 1,0 default path, supports ST but predates L), LogixPccc (logixpccc attribute, full Logix ConnectionSize + L file support via the PCCC compatibility layer on ControlLogix). AbLegacyDriver implements IDriver only — InitializeAsync parses each device's HostAddress and selects its profile (fails fast on malformed strings → Faulted health), per-device state with parsed address + options + profile + empty placeholder for PRs 2-3. ShutdownAsync clears the device dict. 68 new unit tests across 3 files — AbLegacyAddressTests (15 valid shapes + 10 invalid shapes + 7 ToLibplctagName roundtrip), AbLegacyHostAndStatusTests (4 valid host + 5 invalid host + 8 PCCC STS + 7 libplctag status), AbLegacyDriverTests (IDriver lifecycle + multi-device init with per-family profile selection + malformed-address fault + shutdown + family profile defaults + ForFamily theory + data-type mapping). Total project count 29 src + 18 tests; full solution builds 0 errors; Modbus + AbCip + other drivers untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"/>
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
84
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
Normal file
84
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
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, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbLegacyDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
_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 Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_devices.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,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,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,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