From cd2c0bcadd3417a6624ba28624fb7a2565437359 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 18:26:29 -0400 Subject: [PATCH] =?UTF-8?q?TwinCAT=20PR=201=20=E2=80=94=20Scaffolding=20+?= =?UTF-8?q?=20Core=20(TwinCATDriver=20+=20AMS=20address=20+=20symbolic=20p?= =?UTF-8?q?ath).=20New=20Driver.TwinCAT=20project=20referencing=20Beckhoff?= =?UTF-8?q?.TwinCAT.Ads=207.0.172=20(the=20official=20Beckhoff=20.NET=20cl?= =?UTF-8?q?ient=20=E2=80=94=201.6M+=20downloads,=20actively=20maintained?= =?UTF-8?q?=20by=20Beckhoff=20+=20community).=20Package=20compiles=20witho?= =?UTF-8?q?ut=20a=20local=20AMS=20router;=20wire=20calls=20need=20a=20runn?= =?UTF-8?q?ing=20router=20(TwinCAT=20XAR=20on=20dev=20Windows,=20or=20the?= =?UTF-8?q?=20standalone=20Beckhoff.TwinCAT.Ads.TcpRouter=20embedded=20pac?= =?UTF-8?q?kage=20for=20headless/CI).=20Same=20Core.Abstractions-only=20pr?= =?UTF-8?q?oject=20shape=20as=20Modbus=20/=20AbCip=20/=20AbLegacy.=20TwinC?= =?UTF-8?q?ATAmsAddress=20parses=20ads://{netId}:{port}=20canonical=20form?= =?UTF-8?q?=20=E2=80=94=20NetId=20is=206=20dot-separated=20octets=20(NOT?= =?UTF-8?q?=20an=20IP;=20AMS=20router=20translates),=20port=20defaults=20t?= =?UTF-8?q?o=20851=20(TC3=20PLC=20runtime=201).=20Validates=20octet=20rang?= =?UTF-8?q?e=200-255=20and=20port=201-65535.=20Case-insensitive=20scheme.?= =?UTF-8?q?=20Default-port=20stripping=20in=20canonical=20form=20for=20rou?= =?UTF-8?q?ndtrip=20stability.=20Rejects=20wrong=20scheme,=20missing=20//,?= =?UTF-8?q?=205-or-7-octet=20NetId,=20out-of-range=20octets/ports,=20non-n?= =?UTF-8?q?umeric=20fragments.=20TwinCATSymbolPath=20handles=20IEC=2061131?= =?UTF-8?q?-3=20symbolic=20names=20=E2=80=94=20single-segment=20(Counter),?= =?UTF-8?q?=20POU.variable=20(MAIN.bStart),=20GVL.variable=20(GVL.Counter)?= =?UTF-8?q?,=20structured=20member=20access=20(Motor1.Status.Running),=20a?= =?UTF-8?q?rray=20subscripts=20(Data[5]),=20multi-dim=20arrays=20(Matrix[1?= =?UTF-8?q?,2]),=20bit-access=20(Flags.3,=20GVL.Status.7),=20combined=20sc?= =?UTF-8?q?ope/member/subscript/bit=20(MAIN.Motors[0].Status.5).=20Roundtr?= =?UTF-8?q?ip-safe=20ToAdsSymbolName=20produces=20the=20exact=20string=20A?= =?UTF-8?q?dsClient.ReadValue=20consumes.=20Rejects=20leading/trailing=20d?= =?UTF-8?q?ots,=20space=20in=20idents,=20digit-prefix=20idents,=20empty/ne?= =?UTF-8?q?gative/non-numeric=20subscripts,=20unbalanced=20brackets.=20Und?= =?UTF-8?q?erscore-prefix=20idents=20accepted=20per=20IEC.=20TwinCATDataTy?= =?UTF-8?q?pe=20=E2=80=94=20BOOL=20/=20SINT=20/=20USINT=20/=20INT=20/=20UI?= =?UTF-8?q?NT=20/=20DINT=20/=20UDINT=20/=20LINT=20/=20ULINT=20/=20REAL=20/?= =?UTF-8?q?=20LREAL=20/=20STRING=20/=20WSTRING=20(UTF-16)=20/=20TIME=20/?= =?UTF-8?q?=20DATE=20/=20DateTime=20(DT)=20/=20TimeOfDay=20(TOD)=20/=20Str?= =?UTF-8?q?ucture.=20Wider=20than=20Logix's=20surface=20=E2=80=94=20IEC=20?= =?UTF-8?q?adds=20WSTRING=20+=20TIME/DATE/DT/TOD=20variants.=20ToDriverDat?= =?UTF-8?q?aType=20widens=20unsigned=20+=2064-bit=20to=20Int32=20matching?= =?UTF-8?q?=20the=20Modbus/AbCip/AbLegacy=20Int64-gap=20convention.=20Twin?= =?UTF-8?q?CATStatusMapper=20=E2=80=94=20Good=20/=20BadInternalError=20/?= =?UTF-8?q?=20BadNodeIdUnknown=20/=20BadNotWritable=20/=20BadOutOfRange=20?= =?UTF-8?q?/=20BadNotSupported=20/=20BadDeviceFailure=20/=20BadCommunicati?= =?UTF-8?q?onError=20/=20BadTimeout=20/=20BadTypeMismatch.=20MapAdsError?= =?UTF-8?q?=20covers=20the=20ADS=20error=20codes=20a=20driver=20actually?= =?UTF-8?q?=20encounters=20=E2=80=94=206/7=20port=20unreachable,=201792=20?= =?UTF-8?q?service=20not=20supported,=201793/1794=20invalid=20index=20grou?= =?UTF-8?q?p/offset,=201798=20symbol=20not=20found=20(=E2=86=92=20BadNodeI?= =?UTF-8?q?dUnknown),=201807=20invalid=20state,=201808=20access=20denied?= =?UTF-8?q?=20(=E2=86=92=20BadNotWritable),=201811/1812=20size=20mismatch?= =?UTF-8?q?=20(=E2=86=92=20BadOutOfRange),=201861=20sync=20timeout,=20unkn?= =?UTF-8?q?own=20=E2=86=92=20BadCommunicationError.=20TwinCATDriverOptions?= =?UTF-8?q?=20+=20TwinCATDeviceOptions=20+=20TwinCATTagDefinition=20+=20Tw?= =?UTF-8?q?inCATProbeOptions=20=E2=80=94=20one=20instance=20supports=20N?= =?UTF-8?q?=20AMS=20targets,=20Tags=20cross-key=20by=20HostAddress,=20Prob?= =?UTF-8?q?e=20defaults=20to=205s=20interval=20(unlike=20AbLegacy=20there'?= =?UTF-8?q?s=20no=20default=20probe=20address=20=E2=80=94=20ADS=20probe=20?= =?UTF-8?q?reads=20AmsRouterState=20not=20a=20user=20tag,=20so=20probe=20a?= =?UTF-8?q?ddress=20is=20implicit).=20TwinCATDriver=20IDriver=20skeleton?= =?UTF-8?q?=20=E2=80=94=20InitializeAsync=20parses=20each=20device=20HostA?= =?UTF-8?q?ddress=20+=20fails=20fast=20on=20malformed=20strings=20?= =?UTF-8?q?=E2=86=92=20Faulted.=2061=20new=20unit=20tests=20across=203=20f?= =?UTF-8?q?iles=20=E2=80=94=20TwinCATAmsAddressTests=20(6=20valid=20shapes?= =?UTF-8?q?=20+=2012=20invalid=20shapes=20+=202=20ToString=20canonicalisat?= =?UTF-8?q?ion=20+=20roundtrip=20stability),=20TwinCATSymbolPathTests=20(9?= =?UTF-8?q?=20valid=20shapes=20+=2012=20invalid=20shapes=20+=20underscore?= =?UTF-8?q?=20prefix=20+=208-case=20roundtrip),=20TwinCATDriverTests=20(Dr?= =?UTF-8?q?iverType=20+=20multi-device=20init=20+=20malformed-address=20fa?= =?UTF-8?q?ult=20+=20shutdown=20+=20reinit=20+=20data-type=20mapping=20the?= =?UTF-8?q?ory=20+=20ADS=20error-code=20theory).=20Total=20project=20count?= =?UTF-8?q?=2030=20src=20+=2019=20tests;=20full=20solution=20builds=200=20?= =?UTF-8?q?errors;=20Modbus=20/=20AbCip=20/=20AbLegacy=20/=20other=20drive?= =?UTF-8?q?rs=20untouched.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ZB.MOM.WW.OtOpcUa.slnx | 2 + .../TwinCATAmsAddress.cs | 64 ++++++++ .../TwinCATDataType.cs | 49 +++++++ .../TwinCATDriver.cs | 78 ++++++++++ .../TwinCATDriverOptions.cs | 41 ++++++ .../TwinCATStatusMapper.cs | 43 ++++++ .../TwinCATSymbolPath.cs | 103 +++++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj | 31 ++++ .../TwinCATAmsAddressTests.cs | 59 ++++++++ .../TwinCATDriverTests.cs | 105 +++++++++++++ .../TwinCATSymbolPathTests.cs | 138 ++++++++++++++++++ ...MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj | 31 ++++ 12 files changed, 744 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStatusMapper.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATAmsAddressTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolPathTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj 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 + + + + + + + + + + + + + -- 2.49.1