AB Legacy PR 1 � Scaffolding + Core (PCCC address parser) #117

Merged
dohertj2 merged 1 commits from ablegacy-pr1-scaffolding into v2 2026-04-19 17:56:16 -04:00
13 changed files with 753 additions and 0 deletions

View File

@@ -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"/>

View 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,
};
}

View 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,
};
}

View 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;
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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