diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 8aaaf41..574d06a 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -11,6 +11,7 @@
+
@@ -31,6 +32,7 @@
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs
new file mode 100644
index 0000000..3da4f48
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs
@@ -0,0 +1,102 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+///
+/// Parsed PCCC file-based address: file letter + file number + word number, optionally a
+/// sub-element (.ACC on a timer) or bit index (/0 on a bit file).
+///
+///
+/// Logix symbolic tags are parsed elsewhere ( is for SLC / PLC-5 /
+/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number).
+///
+/// - N7:0 — integer file 7, word 0 (signed 16-bit).
+/// - N7:5 — integer file 7, word 5.
+/// - F8:0 — float file 8, word 0 (32-bit IEEE754).
+/// - B3:0/0 — bit file 3, word 0, bit 0.
+/// - ST9:0 — string file 9, string 0 (82-byte fixed-length + length word).
+/// - T4:0.ACC — timer file 4, timer 0, accumulator sub-element.
+/// - C5:0.PRE — counter file 5, counter 0, preset sub-element.
+/// - I:0/0 — input file, slot 0, bit 0 (no file-number for I/O).
+/// - O:1/2 — output file, slot 1, bit 2.
+/// - S:1 — status file, word 1.
+/// - L9:0 — long-integer file (SLC 5/05+, 32-bit).
+///
+/// Pass the original string straight through to libplctag's name=... 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).
+///
+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,
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs
new file mode 100644
index 0000000..8e5bfad
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs
@@ -0,0 +1,45 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+///
+/// 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.
+///
+public enum AbLegacyDataType
+{
+ /// B-file single bit (B3:0/0) or bit-within-N-file (N7:0/3).
+ Bit,
+ /// N-file integer (signed 16-bit).
+ Int,
+ /// L-file long integer — SLC 5/05+ only (signed 32-bit).
+ Long,
+ /// F-file float (32-bit IEEE-754).
+ Float,
+ /// A-file analog integer — some older hardware (signed 16-bit, semantically like N).
+ AnalogInt,
+ /// ST-file string (82-byte fixed-length + length word header).
+ String,
+ /// Timer sub-element — caller addresses .ACC, .PRE, .EN, .DN, .TT.
+ TimerElement,
+ /// Counter sub-element — caller addresses .ACC, .PRE, .CU, .CD, .DN.
+ CounterElement,
+ /// Control sub-element — caller addresses .LEN, .POS, .EN, .DN, .ER.
+ ControlElement,
+}
+
+/// Map a PCCC data type to the driver-surface .
+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,
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
new file mode 100644
index 0000000..f0585c9
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -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;
+
+///
+/// AB Legacy / PCCC driver — SLC 500, MicroLogix, PLC-5, LogixPccc. Implements
+/// only at PR 1 time; read / write / discovery / subscribe / probe /
+/// host-resolver capabilities ship in PRs 2 and 3.
+///
+public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable
+{
+ private readonly AbLegacyDriverOptions _options;
+ private readonly string _driverInstanceId;
+ private readonly Dictionary _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;
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs
new file mode 100644
index 0000000..6ed3112
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs
@@ -0,0 +1,44 @@
+using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+///
+/// 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 (N7:0) and Logix's symbolic addressing
+/// (Motor1.Speed) pull the abstraction in different directions.
+///
+public sealed class AbLegacyDriverOptions
+{
+ public IReadOnlyList Devices { get; init; } = [];
+ public IReadOnlyList 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);
+
+///
+/// One PCCC-backed OPC UA variable. is the canonical PCCC
+/// file-address string that parses via .
+///
+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);
+
+ /// Probe address — defaults to S:0 (status file, first word) when null.
+ public string? ProbeAddress { get; init; } = "S:0";
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs
new file mode 100644
index 0000000..c942230
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs
@@ -0,0 +1,53 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+///
+/// Parsed ab://gateway[:port]/cip-path 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).
+///
+///
+/// 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.
+///
+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);
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs
new file mode 100644
index 0000000..70d17e9
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs
@@ -0,0 +1,57 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+///
+/// 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).
+///
+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;
+
+ ///
+ /// Map libplctag return/status codes. Same polarity as the AbCip mapper — 0 success,
+ /// positive pending, negative error families.
+ ///
+ 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,
+ };
+ }
+
+ ///
+ /// 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.
+ ///
+ 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,
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs
new file mode 100644
index 0000000..3560570
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs
@@ -0,0 +1,64 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
+
+///
+/// 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).
+///
+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
+
+ ///
+ /// 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.
+ ///
+ public static readonly AbLegacyPlcFamilyProfile LogixPccc = new(
+ LibplctagPlcAttribute: "logixpccc",
+ DefaultCipPath: "1,0",
+ MaxTagBytes: 240,
+ SupportsStringFile: true,
+ SupportsLongFile: true);
+}
+
+/// Which PCCC PLC family the device is.
+public enum AbLegacyPlcFamily
+{
+ Slc500,
+ MicroLogix,
+ Plc5,
+ LogixPccc,
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj
new file mode 100644
index 0000000..7162229
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+ $(NoWarn);CS1591
+ ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
+ ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAddressTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAddressTests.cs
new file mode 100644
index 0000000..d032357
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAddressTests.cs
@@ -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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverTests.cs
new file mode 100644
index 0000000..5935d22
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverTests.cs
@@ -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(
+ () => 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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs
new file mode 100644
index 0000000..eb76a1a
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs
@@ -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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj
new file mode 100644
index 0000000..96af460
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+