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