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