diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 574d06a..90401dd 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -12,6 +12,7 @@ + @@ -33,6 +34,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs new file mode 100644 index 0000000..0824cfc --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs @@ -0,0 +1,64 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// Parsed TwinCAT AMS address — six-octet AMS Net ID + port. Canonical form +/// ads://{netId}:{port} where netId is five-dot-separated octets (six of them) +/// and port is the AMS service port (851 = TC3 PLC runtime 1, 852 = runtime 2, 801 / +/// 811 / 821 = TC2 PLC runtimes, 10000 = system service, etc.). +/// +/// +/// Format examples: +/// +/// ads://5.23.91.23.1.1:851 — remote TC3 runtime +/// ads://5.23.91.23.1.1 — defaults to port 851 (TC3 PLC runtime 1) +/// ads://127.0.0.1.1.1:851 — local loopback (when the router is local) +/// +/// AMS Net ID is NOT an IP — it's a Beckhoff-specific identifier that the router +/// translates to an IP route. Typically the first four octets match the host's IPv4 and +/// the last two are .1.1, but the router can be configured otherwise. +/// +public sealed record TwinCATAmsAddress(string NetId, int Port) +{ + /// Default AMS port — TC3 PLC runtime 1. + public const int DefaultPlcPort = 851; + + public override string ToString() => Port == DefaultPlcPort + ? $"ads://{NetId}" + : $"ads://{NetId}:{Port}"; + + public static TwinCATAmsAddress? TryParse(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + const string prefix = "ads://"; + if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null; + + var body = value[prefix.Length..]; + if (string.IsNullOrEmpty(body)) return null; + + var colonIdx = body.LastIndexOf(':'); + string netId; + var port = DefaultPlcPort; + if (colonIdx >= 0) + { + netId = body[..colonIdx]; + if (!int.TryParse(body[(colonIdx + 1)..], out port) || port is <= 0 or > 65535) + return null; + } + else + { + netId = body; + } + + if (!IsValidNetId(netId)) return null; + return new TwinCATAmsAddress(netId, port); + } + + private static bool IsValidNetId(string netId) + { + var parts = netId.Split('.'); + if (parts.Length != 6) return false; + foreach (var p in parts) + if (!byte.TryParse(p, out _)) return false; + return true; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs new file mode 100644 index 0000000..a11fc56 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs @@ -0,0 +1,49 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// TwinCAT / IEC 61131-3 atomic data types. Wider type surface than Logix because IEC adds +/// WSTRING (UTF-16) and TIME/DATE/DT/TOD variants. +/// +public enum TwinCATDataType +{ + Bool, + SInt, // signed 8-bit + USInt, // unsigned 8-bit + Int, // signed 16-bit + UInt, // unsigned 16-bit + DInt, // signed 32-bit + UDInt, // unsigned 32-bit + LInt, // signed 64-bit + ULInt, // unsigned 64-bit + Real, // 32-bit IEEE-754 + LReal, // 64-bit IEEE-754 + String, // ASCII string + WString,// UTF-16 string + Time, // TIME — ms since epoch of day, stored as UDINT + Date, // DATE — days since 1970-01-01, stored as UDINT + DateTime, // DT — seconds since 1970-01-01, stored as UDINT + TimeOfDay,// TOD — ms since midnight, stored as UDINT + /// UDT / FB instance. Resolved per member at discovery time. + Structure, +} + +public static class TwinCATDataTypeExtensions +{ + public static DriverDataType ToDriverDataType(this TwinCATDataType t) => t switch + { + TwinCATDataType.Bool => DriverDataType.Boolean, + TwinCATDataType.SInt or TwinCATDataType.USInt + or TwinCATDataType.Int or TwinCATDataType.UInt + or TwinCATDataType.DInt or TwinCATDataType.UDInt => DriverDataType.Int32, + TwinCATDataType.LInt or TwinCATDataType.ULInt => DriverDataType.Int32, // matches Int64 gap + TwinCATDataType.Real => DriverDataType.Float32, + TwinCATDataType.LReal => DriverDataType.Float64, + TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String, + TwinCATDataType.Time or TwinCATDataType.Date + or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.Int32, + TwinCATDataType.Structure => DriverDataType.String, + _ => DriverDataType.Int32, + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs new file mode 100644 index 0000000..23dd232 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -0,0 +1,78 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// TwinCAT ADS driver — talks to Beckhoff PLC runtimes (TC2 + TC3) via AMS / ADS. PR 1 ships +/// the skeleton; read / write / discover / subscribe / probe / host- +/// resolver land in PRs 2 and 3. +/// +public sealed class TwinCATDriver : IDriver, IDisposable, IAsyncDisposable +{ + private readonly TwinCATDriverOptions _options; + private readonly string _driverInstanceId; + private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); + private DriverHealth _health = new(DriverState.Unknown, null, null); + + public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + _driverInstanceId = driverInstanceId; + } + + public string DriverInstanceId => _driverInstanceId; + public string DriverType => "TwinCAT"; + + public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + _health = new DriverHealth(DriverState.Initializing, null, null); + try + { + foreach (var device in _options.Devices) + { + var addr = TwinCATAmsAddress.TryParse(device.HostAddress) + ?? throw new InvalidOperationException( + $"TwinCAT device has invalid HostAddress '{device.HostAddress}' — expected 'ads://{{netId}}:{{port}}'."); + _devices[device.HostAddress] = new DeviceState(addr, device); + } + _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(TwinCATAmsAddress parsedAddress, TwinCATDeviceOptions options) + { + public TwinCATAmsAddress ParsedAddress { get; } = parsedAddress; + public TwinCATDeviceOptions Options { get; } = options; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs new file mode 100644 index 0000000..4a0a8f4 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs @@ -0,0 +1,41 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// TwinCAT ADS driver configuration. One instance supports N targets (each identified by +/// an AMS Net ID + port). Compiles + runs without a local AMS router but every wire call +/// fails with BadCommunicationError until a router is reachable. +/// +public sealed class TwinCATDriverOptions +{ + public IReadOnlyList Devices { get; init; } = []; + public IReadOnlyList Tags { get; init; } = []; + public TwinCATProbeOptions Probe { get; init; } = new(); + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2); +} + +/// +/// One TwinCAT target. must parse via +/// ; misconfigured devices fail driver initialisation. +/// +public sealed record TwinCATDeviceOptions( + string HostAddress, + string? DeviceName = null); + +/// +/// One TwinCAT-backed OPC UA variable. is the full TwinCAT +/// symbolic name (e.g. MAIN.bStart, GVL.Counter, Motor1.Status.Running). +/// +public sealed record TwinCATTagDefinition( + string Name, + string DeviceHostAddress, + string SymbolPath, + TwinCATDataType DataType, + bool Writable = true, + bool WriteIdempotent = false); + +public sealed class TwinCATProbeOptions +{ + public bool Enabled { get; init; } = true; + public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5); + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStatusMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStatusMapper.cs new file mode 100644 index 0000000..a82831d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStatusMapper.cs @@ -0,0 +1,43 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// Maps AMS / ADS error codes to OPC UA StatusCodes. ADS error codes are defined in +/// AdsErrorCode from Beckhoff.TwinCAT.Ads — this mapper covers the ones a +/// driver actually encounters during normal operation (symbol-not-found, access-denied, +/// timeout, router-not-initialized, invalid-group/offset, etc.). +/// +public static class TwinCATStatusMapper +{ + public const uint Good = 0u; + 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 an AMS / ADS error code (uint from AdsErrorCode enum). 0 = success; non-zero + /// codes follow Beckhoff's AMS error table (7 = target port not found, 1792 = + /// ADSERR_DEVICE_SRVNOTSUPP, 1793 = ADSERR_DEVICE_INVALIDGRP, 1794 = + /// ADSERR_DEVICE_INVALIDOFFSET, 1798 = ADSERR_DEVICE_SYMBOLNOTFOUND, 1808 = + /// ADSERR_DEVICE_ACCESSDENIED, 1861 = ADSERR_CLIENT_SYNCTIMEOUT). + /// + public static uint MapAdsError(uint adsError) => adsError switch + { + 0 => Good, + 6 or 7 => BadCommunicationError, // target port unreachable + 1792 => BadNotSupported, // service not supported + 1793 => BadOutOfRange, // invalid index group + 1794 => BadOutOfRange, // invalid index offset + 1798 => BadNodeIdUnknown, // symbol not found + 1807 => BadDeviceFailure, // device in invalid state + 1808 => BadNotWritable, // access denied + 1811 or 1812 => BadOutOfRange, // size mismatch + 1861 => BadTimeout, // sync timeout + _ => BadCommunicationError, + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs new file mode 100644 index 0000000..e305319 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs @@ -0,0 +1,103 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// Parsed TwinCAT symbolic tag path. Handles global-variable-list (GVL.Counter), +/// program-variable (MAIN.bStart), structured member access +/// (Motor1.Status.Running), array subscripts (Data[5]), multi-dim arrays +/// (Matrix[1,2]), and bit-access (Flags.0). +/// +/// +/// TwinCAT's symbolic syntax mirrors IEC 61131-3 structured-text identifiers — so the +/// grammar maps cleanly onto the AbCip Logix path parser, but without Logix's +/// Program: scope prefix. The leading segment is the namespace (POU name / +/// GVL name) and subsequent segments walk into struct/array members. +/// +public sealed record TwinCATSymbolPath( + IReadOnlyList Segments, + int? BitIndex) +{ + public string ToAdsSymbolName() + { + var buf = new System.Text.StringBuilder(); + for (var i = 0; i < Segments.Count; i++) + { + if (i > 0) buf.Append('.'); + var seg = Segments[i]; + buf.Append(seg.Name); + if (seg.Subscripts.Count > 0) + buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']'); + } + if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value); + return buf.ToString(); + } + + public static TwinCATSymbolPath? TryParse(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var src = value.Trim(); + + var parts = new List(); + var depth = 0; + var start = 0; + for (var i = 0; i < src.Length; i++) + { + var c = src[i]; + if (c == '[') depth++; + else if (c == ']') depth--; + else if (c == '.' && depth == 0) + { + parts.Add(src[start..i]); + start = i + 1; + } + } + parts.Add(src[start..]); + if (depth != 0 || parts.Any(string.IsNullOrEmpty)) return null; + + int? bitIndex = null; + if (parts.Count >= 2 && int.TryParse(parts[^1], out var maybeBit) + && maybeBit is >= 0 and <= 31 + && !parts[^1].Contains('[')) + { + bitIndex = maybeBit; + parts.RemoveAt(parts.Count - 1); + } + + var segments = new List(parts.Count); + foreach (var part in parts) + { + var bracketIdx = part.IndexOf('['); + if (bracketIdx < 0) + { + if (!IsValidIdent(part)) return null; + segments.Add(new TwinCATSymbolSegment(part, [])); + continue; + } + if (!part.EndsWith(']')) return null; + var name = part[..bracketIdx]; + if (!IsValidIdent(name)) return null; + var inner = part[(bracketIdx + 1)..^1]; + var subs = new List(); + foreach (var tok in inner.Split(',')) + { + if (!int.TryParse(tok, out var n) || n < 0) return null; + subs.Add(n); + } + if (subs.Count == 0) return null; + segments.Add(new TwinCATSymbolSegment(name, subs)); + } + if (segments.Count == 0) return null; + + return new TwinCATSymbolPath(segments, bitIndex); + } + + private static bool IsValidIdent(string s) + { + if (string.IsNullOrEmpty(s)) return false; + if (!char.IsLetter(s[0]) && s[0] != '_') return false; + for (var i = 1; i < s.Length; i++) + if (!char.IsLetterOrDigit(s[i]) && s[i] != '_') return false; + return true; + } +} + +public sealed record TwinCATSymbolSegment(string Name, IReadOnlyList Subscripts); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj new file mode 100644 index 0000000..e52b5ba --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.TwinCAT + ZB.MOM.WW.OtOpcUa.Driver.TwinCAT + + + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATAmsAddressTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATAmsAddressTests.cs new file mode 100644 index 0000000..043cecc --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATAmsAddressTests.cs @@ -0,0 +1,59 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; + +[Trait("Category", "Unit")] +public sealed class TwinCATAmsAddressTests +{ + [Theory] + [InlineData("ads://5.23.91.23.1.1:851", "5.23.91.23.1.1", 851)] + [InlineData("ads://5.23.91.23.1.1:852", "5.23.91.23.1.1", 852)] + [InlineData("ads://5.23.91.23.1.1", "5.23.91.23.1.1", 851)] // default port + [InlineData("ads://127.0.0.1.1.1:851", "127.0.0.1.1.1", 851)] + [InlineData("ADS://5.23.91.23.1.1:851", "5.23.91.23.1.1", 851)] // case-insensitive scheme + [InlineData("ads://10.0.0.1.1.1:10000", "10.0.0.1.1.1", 10000)] // system service port + public void TryParse_accepts_valid_ams_addresses(string input, string netId, int port) + { + var parsed = TwinCATAmsAddress.TryParse(input); + parsed.ShouldNotBeNull(); + parsed.NetId.ShouldBe(netId); + parsed.Port.ShouldBe(port); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("tcp://5.23.91.23.1.1:851")] // wrong scheme + [InlineData("ads:5.23.91.23.1.1:851")] // missing // + [InlineData("ads://")] // empty body + [InlineData("ads://5.23.91.23.1:851")] // only 5 octets + [InlineData("ads://5.23.91.23.1.1.1:851")] // 7 octets + [InlineData("ads://5.23.91.256.1.1:851")] // octet > 255 + [InlineData("ads://5.23.91.23.1.1:0")] // port 0 + [InlineData("ads://5.23.91.23.1.1:65536")] // port out of range + [InlineData("ads://5.23.91.23.1.1:abc")] // non-numeric port + [InlineData("ads://a.b.c.d.e.f:851")] // non-numeric octets + public void TryParse_rejects_invalid_forms(string? input) + { + TwinCATAmsAddress.TryParse(input).ShouldBeNull(); + } + + [Theory] + [InlineData("5.23.91.23.1.1", 851, "ads://5.23.91.23.1.1")] // default port stripped + [InlineData("5.23.91.23.1.1", 852, "ads://5.23.91.23.1.1:852")] + public void ToString_canonicalises(string netId, int port, string expected) + { + new TwinCATAmsAddress(netId, port).ToString().ShouldBe(expected); + } + + [Fact] + public void RoundTrip_is_stable() + { + const string input = "ads://5.23.91.23.1.1:852"; + var parsed = TwinCATAmsAddress.TryParse(input)!; + TwinCATAmsAddress.TryParse(parsed.ToString()).ShouldBe(parsed); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs new file mode 100644 index 0000000..fd0e6bf --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs @@ -0,0 +1,105 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; + +[Trait("Category", "Unit")] +public sealed class TwinCATDriverTests +{ + [Fact] + public void DriverType_is_TwinCAT() + { + var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1"); + drv.DriverType.ShouldBe("TwinCAT"); + drv.DriverInstanceId.ShouldBe("drv-1"); + } + + [Fact] + public async Task InitializeAsync_parses_device_addresses() + { + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = + [ + new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"), + new TwinCATDeviceOptions("ads://10.0.0.1.1.1:852", DeviceName: "Machine2"), + ], + }, "drv-1"); + + await drv.InitializeAsync("{}", CancellationToken.None); + + drv.DeviceCount.ShouldBe(2); + drv.GetDeviceState("ads://5.23.91.23.1.1:851")!.ParsedAddress.Port.ShouldBe(851); + drv.GetDeviceState("ads://10.0.0.1.1.1:852")!.Options.DeviceName.ShouldBe("Machine2"); + } + + [Fact] + public async Task InitializeAsync_malformed_address_faults() + { + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("not-an-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 TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.ShutdownAsync(CancellationToken.None); + drv.DeviceCount.ShouldBe(0); + drv.GetHealth().State.ShouldBe(DriverState.Unknown); + } + + [Fact] + public async Task ReinitializeAsync_cycles_devices() + { + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.ReinitializeAsync("{}", CancellationToken.None); + + drv.DeviceCount.ShouldBe(1); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + [Fact] + public void DataType_mapping_covers_atomic_iec_types() + { + TwinCATDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean); + TwinCATDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32); + TwinCATDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32); + TwinCATDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64); + TwinCATDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String); + TwinCATDataType.WString.ToDriverDataType().ShouldBe(DriverDataType.String); + TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Int32); + } + + [Theory] + [InlineData(0u, TwinCATStatusMapper.Good)] + [InlineData(1798u, TwinCATStatusMapper.BadNodeIdUnknown)] // symbol not found + [InlineData(1808u, TwinCATStatusMapper.BadNotWritable)] // access denied + [InlineData(1861u, TwinCATStatusMapper.BadTimeout)] // sync timeout + [InlineData(1793u, TwinCATStatusMapper.BadOutOfRange)] // invalid index group + [InlineData(1794u, TwinCATStatusMapper.BadOutOfRange)] // invalid index offset + [InlineData(1792u, TwinCATStatusMapper.BadNotSupported)] // service not supported + [InlineData(7u, TwinCATStatusMapper.BadCommunicationError)] // port unreachable + [InlineData(99999u, TwinCATStatusMapper.BadCommunicationError)] // unknown → generic comm fail + public void StatusMapper_covers_known_ads_error_codes(uint adsError, uint expected) + { + TwinCATStatusMapper.MapAdsError(adsError).ShouldBe(expected); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolPathTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolPathTests.cs new file mode 100644 index 0000000..a0158d4 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolPathTests.cs @@ -0,0 +1,138 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; + +[Trait("Category", "Unit")] +public sealed class TwinCATSymbolPathTests +{ + [Fact] + public void Single_segment_global_variable_parses() + { + var p = TwinCATSymbolPath.TryParse("Counter"); + p.ShouldNotBeNull(); + p.Segments.Single().Name.ShouldBe("Counter"); + p.ToAdsSymbolName().ShouldBe("Counter"); + } + + [Fact] + public void POU_dot_variable_parses() + { + var p = TwinCATSymbolPath.TryParse("MAIN.bStart"); + p.ShouldNotBeNull(); + p.Segments.Select(s => s.Name).ShouldBe(["MAIN", "bStart"]); + p.ToAdsSymbolName().ShouldBe("MAIN.bStart"); + } + + [Fact] + public void GVL_reference_parses() + { + var p = TwinCATSymbolPath.TryParse("GVL.Counter"); + p.ShouldNotBeNull(); + p.Segments.Select(s => s.Name).ShouldBe(["GVL", "Counter"]); + p.ToAdsSymbolName().ShouldBe("GVL.Counter"); + } + + [Fact] + public void Structured_member_access_splits() + { + var p = TwinCATSymbolPath.TryParse("Motor1.Status.Running"); + p.ShouldNotBeNull(); + p.Segments.Select(s => s.Name).ShouldBe(["Motor1", "Status", "Running"]); + } + + [Fact] + public void Array_subscript_parses() + { + var p = TwinCATSymbolPath.TryParse("Data[5]"); + p.ShouldNotBeNull(); + p.Segments.Single().Subscripts.ShouldBe([5]); + p.ToAdsSymbolName().ShouldBe("Data[5]"); + } + + [Fact] + public void Multi_dim_array_subscript_parses() + { + var p = TwinCATSymbolPath.TryParse("Matrix[1,2]"); + p.ShouldNotBeNull(); + p.Segments.Single().Subscripts.ShouldBe([1, 2]); + } + + [Fact] + public void Bit_access_captured_as_bit_index() + { + var p = TwinCATSymbolPath.TryParse("Flags.3"); + p.ShouldNotBeNull(); + p.Segments.Single().Name.ShouldBe("Flags"); + p.BitIndex.ShouldBe(3); + p.ToAdsSymbolName().ShouldBe("Flags.3"); + } + + [Fact] + public void Bit_access_after_member_path() + { + var p = TwinCATSymbolPath.TryParse("GVL.Status.7"); + p.ShouldNotBeNull(); + p.Segments.Select(s => s.Name).ShouldBe(["GVL", "Status"]); + p.BitIndex.ShouldBe(7); + } + + [Fact] + public void Combined_scope_member_subscript_bit() + { + var p = TwinCATSymbolPath.TryParse("MAIN.Motors[0].Status.5"); + p.ShouldNotBeNull(); + p.Segments.Select(s => s.Name).ShouldBe(["MAIN", "Motors", "Status"]); + p.Segments[1].Subscripts.ShouldBe([0]); + p.BitIndex.ShouldBe(5); + p.ToAdsSymbolName().ShouldBe("MAIN.Motors[0].Status.5"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(".Motor")] // leading dot + [InlineData("Motor.")] // trailing dot + [InlineData("Motor.[0]")] // empty segment + [InlineData("1bad")] // ident starts with digit + [InlineData("Bad Name")] // space in ident + [InlineData("Motor[]")] // empty subscript + [InlineData("Motor[-1]")] // negative subscript + [InlineData("Motor[a]")] // non-numeric subscript + [InlineData("Motor[")] // unbalanced bracket + [InlineData("Flags.32")] // bit out of range (treated as ident → invalid shape) + public void Invalid_shapes_return_null(string? input) + { + TwinCATSymbolPath.TryParse(input).ShouldBeNull(); + } + + [Fact] + public void Underscore_prefix_idents_accepted() + { + TwinCATSymbolPath.TryParse("_internal_var")!.Segments.Single().Name.ShouldBe("_internal_var"); + } + + [Fact] + public void ToAdsSymbolName_roundtrips() + { + var cases = new[] + { + "Counter", + "MAIN.bStart", + "GVL.Counter", + "Motor1.Status.Running", + "Data[5]", + "Matrix[1,2]", + "Flags.3", + "MAIN.Motors[0].Status.5", + }; + foreach (var c in cases) + { + var parsed = TwinCATSymbolPath.TryParse(c); + parsed.ShouldNotBeNull(c); + parsed.ToAdsSymbolName().ShouldBe(c); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj new file mode 100644 index 0000000..7826c71 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + +