Compare commits

...

4 Commits

Author SHA1 Message Date
Joseph Doherty
b2424a0616 AB Legacy PR 2 — IReadable + IWritable. IAbLegacyTagRuntime + IAbLegacyTagFactory abstraction mirrors IAbCipTagRuntime from AbCip PR 3. LibplctagLegacyTagRuntime default implementation wraps libplctag.Tag with Protocol=ab_eip + PlcType dispatched from the profile's libplctag attribute (Slc500/MicroLogix/Plc5/LogixPccc) — libplctag routes PCCC-over-EIP internally based on PlcType, so our layer just forwards the atomic type to Get/Set calls. DecodeValue handles Bit (GetBit when bitIndex is set, else GetInt8!=0), Int/AnalogInt (GetInt16 widened to int), Long (GetInt32), Float (GetFloat32), String (GetString), TimerElement/CounterElement/ControlElement (GetInt32 — sub-element selection is in the libplctag tag name like T4:0.ACC, PLC-side decode picks the right slot). EncodeValue handles the same types; bit-within-word writes throw NotSupportedException pointing at follow-up task #181 (same read-modify-write gap as Modbus BitInRegister). AbLegacyDriver implements IReadable + IWritable with the exact same shape as AbCip PR 3-4 — per-tag lazy runtime init via EnsureTagRuntimeAsync cached in DeviceState.Runtimes dict, ordered-snapshot results, health surface updates. Exception table — OperationCanceledException rethrows, NotSupportedException → BadNotSupported, FormatException/InvalidCastException → BadTypeMismatch (guard pattern C# 11 syntax), OverflowException → BadOutOfRange, anything else → BadCommunicationError. ShutdownAsync disposes every cached runtime so the native tag handles get released. 14 new unit tests in AbLegacyReadWriteTests covering unknown ref → BadNodeIdUnknown, successful N-file read with Good status + captured value, repeat-read reuses cached runtime (init count 1 across 2 reads), libplctag non-zero status mapping (-14 → BadNodeIdUnknown), read exception → BadCommunicationError + Degraded health, batched reads preserve order across N/F/ST types, TagCreateParams composition (gateway/port/path/slc500 attribute/tag-name), non-writable tag → BadNotWritable, successful write encodes + flushes, bit-within-word → BadNotSupported (RmwThrowingFake mirrors LibplctagLegacyTagRuntime's runtime check), write exception → BadCommunicationError, batch preserves order across success+fail+unknown, cancellation propagates, ShutdownAsync disposes runtimes. Total AbLegacy unit tests now 82/82 passing (+14 from PR 1's 68). Full solution builds 0 errors; Modbus + AbCip + other drivers untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:58:38 -04:00
59c99190c6 Merge pull request (#117) - AbLegacy scaffolding 2026-04-19 17:56:15 -04:00
Joseph Doherty
fc575e8dae 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>
2026-04-19 17:54:25 -04:00
70f5f2cad1 Merge pull request (#116) - AbCip family profiles 2026-04-19 17:20:43 -04:00
17 changed files with 1356 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,246 @@
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, IDisposable, IAsyncDisposable
{
private readonly AbLegacyDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IAbLegacyTagFactory _tagFactory;
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 AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId,
IAbLegacyTagFactory? tagFactory = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
}
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;
_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)
{
foreach (var state in _devices.Values) state.DisposeRuntimes();
_devices.Clear();
_tagsByName.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;
// ---- 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;
}
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 void DisposeRuntimes()
{
foreach (var r in Runtimes.Values) r.Dispose();
Runtimes.Clear();
}
}
}

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,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);

View File

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

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

View File

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

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>