Compare commits
6 Commits
ablegacy-p
...
twincat-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28e3470300 | ||
| bffac4db65 | |||
|
|
cd2c0bcadd | ||
| 7fdf4e5618 | |||
|
|
400fc6242c | ||
| 4438fdd7b1 |
@@ -12,6 +12,7 @@
|
|||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||||
|
|||||||
@@ -8,15 +8,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
|||||||
/// <see cref="IDriver"/> only at PR 1 time; read / write / discovery / subscribe / probe /
|
/// <see cref="IDriver"/> only at PR 1 time; read / write / discovery / subscribe / probe /
|
||||||
/// host-resolver capabilities ship in PRs 2 and 3.
|
/// host-resolver capabilities ship in PRs 2 and 3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable
|
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||||
|
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly AbLegacyDriverOptions _options;
|
private readonly AbLegacyDriverOptions _options;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
private readonly IAbLegacyTagFactory _tagFactory;
|
private readonly IAbLegacyTagFactory _tagFactory;
|
||||||
|
private readonly PollGroupEngine _poll;
|
||||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||||
|
|
||||||
public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId,
|
public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId,
|
||||||
IAbLegacyTagFactory? tagFactory = null)
|
IAbLegacyTagFactory? tagFactory = null)
|
||||||
{
|
{
|
||||||
@@ -24,6 +29,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable,
|
|||||||
_options = options;
|
_options = options;
|
||||||
_driverInstanceId = driverInstanceId;
|
_driverInstanceId = driverInstanceId;
|
||||||
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
||||||
|
_poll = new PollGroupEngine(
|
||||||
|
reader: ReadAsync,
|
||||||
|
onChange: (handle, tagRef, snapshot) =>
|
||||||
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public string DriverInstanceId => _driverInstanceId;
|
public string DriverInstanceId => _driverInstanceId;
|
||||||
@@ -43,6 +52,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable,
|
|||||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||||||
}
|
}
|
||||||
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
||||||
|
|
||||||
|
// Probe loops — one per device when enabled + probe address configured.
|
||||||
|
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress))
|
||||||
|
{
|
||||||
|
foreach (var state in _devices.Values)
|
||||||
|
{
|
||||||
|
state.ProbeCts = new CancellationTokenSource();
|
||||||
|
var ct = state.ProbeCts.Token;
|
||||||
|
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -59,13 +79,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable,
|
|||||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task ShutdownAsync(CancellationToken cancellationToken)
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
foreach (var state in _devices.Values) state.DisposeRuntimes();
|
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||||
|
foreach (var state in _devices.Values)
|
||||||
|
{
|
||||||
|
try { state.ProbeCts?.Cancel(); } catch { }
|
||||||
|
state.ProbeCts?.Dispose();
|
||||||
|
state.ProbeCts = null;
|
||||||
|
state.DisposeRuntimes();
|
||||||
|
}
|
||||||
_devices.Clear();
|
_devices.Clear();
|
||||||
_tagsByName.Clear();
|
_tagsByName.Clear();
|
||||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public DriverHealth GetHealth() => _health;
|
public DriverHealth GetHealth() => _health;
|
||||||
@@ -194,6 +220,117 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable,
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ITagDiscovery ----
|
||||||
|
|
||||||
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(builder);
|
||||||
|
var root = builder.Folder("AbLegacy", "AbLegacy");
|
||||||
|
foreach (var device in _options.Devices)
|
||||||
|
{
|
||||||
|
var label = device.DeviceName ?? device.HostAddress;
|
||||||
|
var deviceFolder = root.Folder(device.HostAddress, label);
|
||||||
|
var tagsForDevice = _options.Tags.Where(t =>
|
||||||
|
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||||
|
foreach (var tag in tagsForDevice)
|
||||||
|
{
|
||||||
|
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||||
|
FullName: tag.Name,
|
||||||
|
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||||
|
IsArray: false,
|
||||||
|
ArrayDim: null,
|
||||||
|
SecurityClass: tag.Writable
|
||||||
|
? SecurityClassification.Operate
|
||||||
|
: SecurityClassification.ViewOnly,
|
||||||
|
IsHistorized: false,
|
||||||
|
IsAlarm: false,
|
||||||
|
WriteIdempotent: tag.WriteIdempotent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||||
|
|
||||||
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||||||
|
|
||||||
|
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_poll.Unsubscribe(handle);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IHostConnectivityProbe ----
|
||||||
|
|
||||||
|
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||||
|
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||||
|
|
||||||
|
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var probeParams = new AbLegacyTagCreateParams(
|
||||||
|
Gateway: state.ParsedAddress.Gateway,
|
||||||
|
Port: state.ParsedAddress.Port,
|
||||||
|
CipPath: state.ParsedAddress.CipPath,
|
||||||
|
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||||
|
TagName: _options.Probe.ProbeAddress!,
|
||||||
|
Timeout: _options.Probe.Timeout);
|
||||||
|
|
||||||
|
IAbLegacyTagRuntime? probeRuntime = null;
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var success = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
probeRuntime ??= _tagFactory.Create(probeParams);
|
||||||
|
if (!state.ProbeInitialized)
|
||||||
|
{
|
||||||
|
await probeRuntime.InitializeAsync(ct).ConfigureAwait(false);
|
||||||
|
state.ProbeInitialized = true;
|
||||||
|
}
|
||||||
|
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||||
|
success = probeRuntime.GetStatus() == 0;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
try { probeRuntime?.Dispose(); } catch { }
|
||||||
|
probeRuntime = null;
|
||||||
|
state.ProbeInitialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||||
|
|
||||||
|
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
try { probeRuntime?.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||||
|
{
|
||||||
|
HostState old;
|
||||||
|
lock (state.ProbeLock)
|
||||||
|
{
|
||||||
|
old = state.HostState;
|
||||||
|
if (old == newState) return;
|
||||||
|
state.HostState = newState;
|
||||||
|
state.HostStateChangedUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
OnHostStatusChanged?.Invoke(this,
|
||||||
|
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IPerCallHostResolver ----
|
||||||
|
|
||||||
|
public string ResolveHost(string fullReference)
|
||||||
|
{
|
||||||
|
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||||
|
return def.DeviceHostAddress;
|
||||||
|
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
||||||
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -237,6 +374,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable,
|
|||||||
public Dictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
|
public Dictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public object ProbeLock { get; } = new();
|
||||||
|
public HostState HostState { get; set; } = HostState.Unknown;
|
||||||
|
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
public CancellationTokenSource? ProbeCts { get; set; }
|
||||||
|
public bool ProbeInitialized { get; set; }
|
||||||
|
|
||||||
public void DisposeRuntimes()
|
public void DisposeRuntimes()
|
||||||
{
|
{
|
||||||
foreach (var r in Runtimes.Values) r.Dispose();
|
foreach (var r in Runtimes.Values) r.Dispose();
|
||||||
|
|||||||
154
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
Normal file
154
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
using TwinCAT.Ads;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="ITwinCATClient"/> backed by Beckhoff's <see cref="AdsClient"/>.
|
||||||
|
/// One instance per AMS target; reused across reads / writes / probes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Wire behavior depends on a reachable AMS router — on Windows the router comes
|
||||||
|
/// from TwinCAT XAR; elsewhere from the <c>Beckhoff.TwinCAT.Ads.TcpRouter</c> package
|
||||||
|
/// hosted by the server process. Neither is built-in here; deployment wires one in.</para>
|
||||||
|
///
|
||||||
|
/// <para>Error mapping — ADS error codes surface through <see cref="AdsErrorException"/>
|
||||||
|
/// and get translated to OPC UA status codes via <see cref="TwinCATStatusMapper.MapAdsError"/>.</para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||||
|
{
|
||||||
|
private readonly AdsClient _client = new();
|
||||||
|
|
||||||
|
public bool IsConnected => _client.IsConnected;
|
||||||
|
|
||||||
|
public Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_client.IsConnected) return Task.CompletedTask;
|
||||||
|
_client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds);
|
||||||
|
var netId = AmsNetId.Parse(address.NetId);
|
||||||
|
_client.Connect(netId, address.Port);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(object? value, uint status)> ReadValueAsync(
|
||||||
|
string symbolPath,
|
||||||
|
TwinCATDataType type,
|
||||||
|
int? bitIndex,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var clrType = MapToClrType(type);
|
||||||
|
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||||
|
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
|
||||||
|
|
||||||
|
var value = result.Value;
|
||||||
|
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
|
||||||
|
value = ExtractBit(value, bit);
|
||||||
|
|
||||||
|
return (value, TwinCATStatusMapper.Good);
|
||||||
|
}
|
||||||
|
catch (AdsErrorException ex)
|
||||||
|
{
|
||||||
|
return (null, TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<uint> WriteValueAsync(
|
||||||
|
string symbolPath,
|
||||||
|
TwinCATDataType type,
|
||||||
|
int? bitIndex,
|
||||||
|
object? value,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (bitIndex is int && type == TwinCATDataType.Bool)
|
||||||
|
throw new NotSupportedException(
|
||||||
|
"BOOL-within-word writes require read-modify-write; tracked in task #181.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var converted = ConvertForWrite(type, value);
|
||||||
|
var result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return result.ErrorCode == AdsErrorCode.NoError
|
||||||
|
? TwinCATStatusMapper.Good
|
||||||
|
: TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode);
|
||||||
|
}
|
||||||
|
catch (AdsErrorException ex)
|
||||||
|
{
|
||||||
|
return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var state = await _client.ReadStateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return state.ErrorCode == AdsErrorCode.NoError;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _client.Dispose();
|
||||||
|
|
||||||
|
private static Type MapToClrType(TwinCATDataType type) => type switch
|
||||||
|
{
|
||||||
|
TwinCATDataType.Bool => typeof(bool),
|
||||||
|
TwinCATDataType.SInt => typeof(sbyte),
|
||||||
|
TwinCATDataType.USInt => typeof(byte),
|
||||||
|
TwinCATDataType.Int => typeof(short),
|
||||||
|
TwinCATDataType.UInt => typeof(ushort),
|
||||||
|
TwinCATDataType.DInt => typeof(int),
|
||||||
|
TwinCATDataType.UDInt => typeof(uint),
|
||||||
|
TwinCATDataType.LInt => typeof(long),
|
||||||
|
TwinCATDataType.ULInt => typeof(ulong),
|
||||||
|
TwinCATDataType.Real => typeof(float),
|
||||||
|
TwinCATDataType.LReal => typeof(double),
|
||||||
|
TwinCATDataType.String or TwinCATDataType.WString => typeof(string),
|
||||||
|
TwinCATDataType.Time or TwinCATDataType.Date
|
||||||
|
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => typeof(uint),
|
||||||
|
_ => typeof(int),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
|
||||||
|
{
|
||||||
|
TwinCATDataType.Bool => Convert.ToBoolean(value),
|
||||||
|
TwinCATDataType.SInt => Convert.ToSByte(value),
|
||||||
|
TwinCATDataType.USInt => Convert.ToByte(value),
|
||||||
|
TwinCATDataType.Int => Convert.ToInt16(value),
|
||||||
|
TwinCATDataType.UInt => Convert.ToUInt16(value),
|
||||||
|
TwinCATDataType.DInt => Convert.ToInt32(value),
|
||||||
|
TwinCATDataType.UDInt => Convert.ToUInt32(value),
|
||||||
|
TwinCATDataType.LInt => Convert.ToInt64(value),
|
||||||
|
TwinCATDataType.ULInt => Convert.ToUInt64(value),
|
||||||
|
TwinCATDataType.Real => Convert.ToSingle(value),
|
||||||
|
TwinCATDataType.LReal => Convert.ToDouble(value),
|
||||||
|
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
|
||||||
|
TwinCATDataType.Time or TwinCATDataType.Date
|
||||||
|
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => Convert.ToUInt32(value),
|
||||||
|
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool ExtractBit(object? rawWord, int bit) => rawWord switch
|
||||||
|
{
|
||||||
|
short s => (s & (1 << bit)) != 0,
|
||||||
|
ushort us => (us & (1 << bit)) != 0,
|
||||||
|
int i => (i & (1 << bit)) != 0,
|
||||||
|
uint ui => (ui & (1u << bit)) != 0,
|
||||||
|
long l => (l & (1L << bit)) != 0,
|
||||||
|
ulong ul => (ul & (1UL << bit)) != 0,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Default <see cref="ITwinCATClientFactory"/> — one <see cref="AdsTwinCATClient"/> per call.</summary>
|
||||||
|
internal sealed class AdsTwinCATClientFactory : ITwinCATClientFactory
|
||||||
|
{
|
||||||
|
public ITwinCATClient Create() => new AdsTwinCATClient();
|
||||||
|
}
|
||||||
55
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
Normal file
55
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-layer abstraction over one connection to a TwinCAT AMS target. One instance per
|
||||||
|
/// <see cref="TwinCATAmsAddress"/>; reused across reads / writes / probes for the device.
|
||||||
|
/// Tests swap in a fake via <see cref="ITwinCATClientFactory"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Unlike libplctag-backed drivers where one native handle exists per tag, TwinCAT's
|
||||||
|
/// AdsClient is one connection per target with symbolic reads / writes issued against it.
|
||||||
|
/// The abstraction reflects that — single <see cref="ConnectAsync"/>, many
|
||||||
|
/// <see cref="ReadValueAsync"/> / <see cref="WriteValueAsync"/> calls.
|
||||||
|
/// </remarks>
|
||||||
|
public interface ITwinCATClient : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Establish the AMS connection. Idempotent — subsequent calls are no-ops when already connected.</summary>
|
||||||
|
Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>True when the AMS router + target both accept commands.</summary>
|
||||||
|
bool IsConnected { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read a symbolic value. Returns a boxed .NET value matching the requested
|
||||||
|
/// <paramref name="type"/>, or <c>null</c> when the read produced no data; the
|
||||||
|
/// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good).
|
||||||
|
/// </summary>
|
||||||
|
Task<(object? value, uint status)> ReadValueAsync(
|
||||||
|
string symbolPath,
|
||||||
|
TwinCATDataType type,
|
||||||
|
int? bitIndex,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
|
||||||
|
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
|
||||||
|
/// </summary>
|
||||||
|
Task<uint> WriteValueAsync(
|
||||||
|
string symbolPath,
|
||||||
|
TwinCATDataType type,
|
||||||
|
int? bitIndex,
|
||||||
|
object? value,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable.
|
||||||
|
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Factory for <see cref="ITwinCATClient"/>s. One client per device.</summary>
|
||||||
|
public interface ITwinCATClientFactory
|
||||||
|
{
|
||||||
|
ITwinCATClient Create();
|
||||||
|
}
|
||||||
64
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsed TwinCAT AMS address — six-octet AMS Net ID + port. Canonical form
|
||||||
|
/// <c>ads://{netId}:{port}</c> where <c>netId</c> is five-dot-separated octets (six of them)
|
||||||
|
/// and <c>port</c> is the AMS service port (851 = TC3 PLC runtime 1, 852 = runtime 2, 801 /
|
||||||
|
/// 811 / 821 = TC2 PLC runtimes, 10000 = system service, etc.).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Format examples:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>ads://5.23.91.23.1.1:851</c> — remote TC3 runtime</item>
|
||||||
|
/// <item><c>ads://5.23.91.23.1.1</c> — defaults to port 851 (TC3 PLC runtime 1)</item>
|
||||||
|
/// <item><c>ads://127.0.0.1.1.1:851</c> — local loopback (when the router is local)</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>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 <c>.1.1</c>, but the router can be configured otherwise.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record TwinCATAmsAddress(string NetId, int Port)
|
||||||
|
{
|
||||||
|
/// <summary>Default AMS port — TC3 PLC runtime 1.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs
Normal file
49
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TwinCAT / IEC 61131-3 atomic data types. Wider type surface than Logix because IEC adds
|
||||||
|
/// <c>WSTRING</c> (UTF-16) and <c>TIME</c>/<c>DATE</c>/<c>DT</c>/<c>TOD</c> variants.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
/// <summary>UDT / FB instance. Resolved per member at discovery time.</summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
219
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
Normal file
219
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TwinCAT ADS driver — talks to Beckhoff PLC runtimes (TC2 + TC3) via AMS / ADS. PR 1 ships
|
||||||
|
/// the <see cref="IDriver"/> skeleton; read / write / discover / subscribe / probe / host-
|
||||||
|
/// resolver land in PRs 2 and 3.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly TwinCATDriverOptions _options;
|
||||||
|
private readonly string _driverInstanceId;
|
||||||
|
private readonly ITwinCATClientFactory _clientFactory;
|
||||||
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, TwinCATTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
|
public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId,
|
||||||
|
ITwinCATClientFactory? clientFactory = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
_options = options;
|
||||||
|
_driverInstanceId = driverInstanceId;
|
||||||
|
_clientFactory = clientFactory ?? new AdsTwinCATClientFactory();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
||||||
|
_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)
|
||||||
|
{
|
||||||
|
foreach (var state in _devices.Values) state.DisposeClient();
|
||||||
|
_devices.Clear();
|
||||||
|
_tagsByName.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;
|
||||||
|
|
||||||
|
// ---- IReadable ----
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var results = new DataValueSnapshot[fullReferences.Count];
|
||||||
|
|
||||||
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
|
{
|
||||||
|
var reference = fullReferences[i];
|
||||||
|
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||||
|
{
|
||||||
|
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||||
|
{
|
||||||
|
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
|
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||||
|
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||||
|
var (value, status) = await client.ReadValueAsync(
|
||||||
|
symbolName, def.DataType, parsed?.BitIndex, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||||
|
if (status == TwinCATStatusMapper.Good)
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
|
else
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||||
|
$"ADS status {status:X8} reading {reference}");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IWritable ----
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||||
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(writes);
|
||||||
|
var results = new WriteResult[writes.Count];
|
||||||
|
|
||||||
|
for (var i = 0; i < writes.Count; i++)
|
||||||
|
{
|
||||||
|
var w = writes[i];
|
||||||
|
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!def.Writable)
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(TwinCATStatusMapper.BadNotWritable);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
|
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||||
|
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||||
|
var status = await client.WriteValueAsync(
|
||||||
|
symbolName, def.DataType, parsed?.BitIndex, w.Value, cancellationToken).ConfigureAwait(false);
|
||||||
|
results[i] = new WriteResult(status);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (NotSupportedException nse)
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(TwinCATStatusMapper.BadNotSupported);
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
|
||||||
|
}
|
||||||
|
catch (OverflowException)
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(TwinCATStatusMapper.BadOutOfRange);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ITwinCATClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (device.Client is { IsConnected: true } c) return c;
|
||||||
|
device.Client ??= _clientFactory.Create();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
device.Client.Dispose();
|
||||||
|
device.Client = null;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
return device.Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
public ITwinCATClient? Client { get; set; }
|
||||||
|
|
||||||
|
public void DisposeClient()
|
||||||
|
{
|
||||||
|
Client?.Dispose();
|
||||||
|
Client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs
Normal file
41
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>BadCommunicationError</c> until a router is reachable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
public IReadOnlyList<TwinCATDeviceOptions> Devices { get; init; } = [];
|
||||||
|
public IReadOnlyList<TwinCATTagDefinition> Tags { get; init; } = [];
|
||||||
|
public TwinCATProbeOptions Probe { get; init; } = new();
|
||||||
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One TwinCAT target. <paramref name="HostAddress"/> must parse via
|
||||||
|
/// <see cref="TwinCATAmsAddress.TryParse"/>; misconfigured devices fail driver initialisation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TwinCATDeviceOptions(
|
||||||
|
string HostAddress,
|
||||||
|
string? DeviceName = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One TwinCAT-backed OPC UA variable. <paramref name="SymbolPath"/> is the full TwinCAT
|
||||||
|
/// symbolic name (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>).
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
43
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStatusMapper.cs
Normal file
43
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStatusMapper.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps AMS / ADS error codes to OPC UA StatusCodes. ADS error codes are defined in
|
||||||
|
/// <c>AdsErrorCode</c> from <c>Beckhoff.TwinCAT.Ads</c> — 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.).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
103
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs
Normal file
103
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsed TwinCAT symbolic tag path. Handles global-variable-list (<c>GVL.Counter</c>),
|
||||||
|
/// program-variable (<c>MAIN.bStart</c>), structured member access
|
||||||
|
/// (<c>Motor1.Status.Running</c>), array subscripts (<c>Data[5]</c>), multi-dim arrays
|
||||||
|
/// (<c>Matrix[1,2]</c>), and bit-access (<c>Flags.0</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>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
|
||||||
|
/// <c>Program:</c> scope prefix. The leading segment is the namespace (POU name /
|
||||||
|
/// GVL name) and subsequent segments walk into struct/array members.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record TwinCATSymbolPath(
|
||||||
|
IReadOnlyList<TwinCATSymbolSegment> 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<string>();
|
||||||
|
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<TwinCATSymbolSegment>(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<int>();
|
||||||
|
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<int> Subscripts);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.TwinCAT</RootNamespace>
|
||||||
|
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.TwinCAT</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Official Beckhoff ADS client. Requires a running AMS router (TwinCAT XAR, TwinCAT HMI
|
||||||
|
Server, or the standalone Beckhoff.TwinCAT.Ads.TcpRouter package) to reach remote
|
||||||
|
systems. The router is a runtime concern, not a build concern — the library compiles
|
||||||
|
+ runs fine without one; ADS calls just fail with transport errors. -->
|
||||||
|
<PackageReference Include="Beckhoff.TwinCAT.Ads" Version="7.0.172"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbLegacyCapabilityTests
|
||||||
|
{
|
||||||
|
// ---- ITagDiscovery ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverAsync_emits_pre_declared_tags_under_device_folder()
|
||||||
|
{
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Press-SLC-1")],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbLegacyTagDefinition("Speed", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||||
|
new AbLegacyTagDefinition("Temperature", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float, Writable: false),
|
||||||
|
],
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
builder.Folders.ShouldContain(f => f.BrowseName == "AbLegacy");
|
||||||
|
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Press-SLC-1");
|
||||||
|
builder.Variables.Count.ShouldBe(2);
|
||||||
|
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||||
|
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ISubscribable ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Subscribe_initial_poll_raises_OnDataChange()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbLegacyTagFactory
|
||||||
|
{
|
||||||
|
Customise = p => new FakeAbLegacyTag(p) { Value = 42 },
|
||||||
|
};
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||||
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||||
|
|
||||||
|
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||||
|
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
events.First().Snapshot.Value.ShouldBe(42);
|
||||||
|
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unsubscribe_halts_polling()
|
||||||
|
{
|
||||||
|
var tagRef = new FakeAbLegacyTag(
|
||||||
|
new AbLegacyTagCreateParams("10.0.0.5", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2))) { Value = 1 };
|
||||||
|
var factory = new FakeAbLegacyTagFactory { Customise = _ => tagRef };
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||||
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||||
|
|
||||||
|
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||||
|
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||||
|
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||||
|
|
||||||
|
var afterUnsub = events.Count;
|
||||||
|
tagRef.Value = 999;
|
||||||
|
await Task.Delay(300);
|
||||||
|
events.Count.ShouldBe(afterUnsub);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IHostConnectivityProbe ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHostStatuses_returns_one_per_device()
|
||||||
|
{
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"),
|
||||||
|
new AbLegacyDeviceOptions("ab://10.0.0.6/1,0"),
|
||||||
|
],
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.GetHostStatuses().Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_transitions_to_Running_on_successful_read()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) };
|
||||||
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Probe = new AbLegacyProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0",
|
||||||
|
},
|
||||||
|
}, "drv-1", factory);
|
||||||
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_transitions_to_Stopped_on_read_failure()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true } };
|
||||||
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Probe = new AbLegacyProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0",
|
||||||
|
},
|
||||||
|
}, "drv-1", factory);
|
||||||
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_disabled_when_ProbeAddress_is_null()
|
||||||
|
{
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = true, ProbeAddress = null },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IPerCallHostResolver ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
||||||
|
{
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"),
|
||||||
|
new AbLegacyDeviceOptions("ab://10.0.0.6/1,0"),
|
||||||
|
],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||||
|
new AbLegacyTagDefinition("B", "ab://10.0.0.6/1,0", "N7:0", AbLegacyDataType.Int),
|
||||||
|
],
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.ResolveHost("A").ShouldBe("ab://10.0.0.5/1,0");
|
||||||
|
drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveHost_falls_back_to_first_device_for_unknown()
|
||||||
|
{
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.ResolveHost("missing").ShouldBe("ab://10.0.0.5/1,0");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||||
|
{
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.ResolveHost("anything").ShouldBe("drv-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (!condition() && DateTime.UtcNow < deadline)
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||||
|
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||||
|
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||||
|
{ Folders.Add((browseName, displayName)); return this; }
|
||||||
|
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||||
|
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||||
|
|
||||||
|
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||||
|
|
||||||
|
private sealed class Handle(string fullRef) : IVariableHandle
|
||||||
|
{
|
||||||
|
public string FullReference => fullRef;
|
||||||
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||||
|
}
|
||||||
|
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||||
|
|
||||||
|
internal class FakeTwinCATClient : ITwinCATClient
|
||||||
|
{
|
||||||
|
public bool IsConnected { get; private set; }
|
||||||
|
public int ConnectCount { get; private set; }
|
||||||
|
public int DisposeCount { get; private set; }
|
||||||
|
public bool ThrowOnConnect { get; set; }
|
||||||
|
public bool ThrowOnRead { get; set; }
|
||||||
|
public bool ThrowOnWrite { get; set; }
|
||||||
|
public bool ThrowOnProbe { get; set; }
|
||||||
|
public Exception? Exception { get; set; }
|
||||||
|
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();
|
||||||
|
public bool ProbeResult { get; set; } = true;
|
||||||
|
|
||||||
|
public virtual Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ConnectCount++;
|
||||||
|
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
|
||||||
|
IsConnected = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual Task<(object? value, uint status)> ReadValueAsync(
|
||||||
|
string symbolPath, TwinCATDataType type, int? bitIndex, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||||
|
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
|
||||||
|
var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
|
||||||
|
return Task.FromResult((value, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual Task<uint> WriteValueAsync(
|
||||||
|
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||||
|
WriteLog.Add((symbolPath, type, bitIndex, value));
|
||||||
|
Values[symbolPath] = value;
|
||||||
|
var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
|
||||||
|
return Task.FromResult(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual Task<bool> ProbeAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (ThrowOnProbe) return Task.FromResult(false);
|
||||||
|
return Task.FromResult(ProbeResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void Dispose()
|
||||||
|
{
|
||||||
|
DisposeCount++;
|
||||||
|
IsConnected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FakeTwinCATClientFactory : ITwinCATClientFactory
|
||||||
|
{
|
||||||
|
public List<FakeTwinCATClient> Clients { get; } = new();
|
||||||
|
public Func<FakeTwinCATClient>? Customise { get; set; }
|
||||||
|
|
||||||
|
public ITwinCATClient Create()
|
||||||
|
{
|
||||||
|
var client = Customise?.Invoke() ?? new FakeTwinCATClient();
|
||||||
|
Clients.Add(client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<InvalidOperationException>(
|
||||||
|
() => 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
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 TwinCATReadWriteTests
|
||||||
|
{
|
||||||
|
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(params TwinCATTagDefinition[] tags)
|
||||||
|
{
|
||||||
|
var factory = new FakeTwinCATClientFactory();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Tags = tags,
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
return (drv, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Read ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||||
|
{
|
||||||
|
var (drv, _) = NewDriver();
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None);
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Successful_DInt_read_returns_Good_value()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 4200 } };
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||||
|
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
||||||
|
snapshots.Single().Value.ShouldBe(4200);
|
||||||
|
factory.Clients[0].ConnectCount.ShouldBe(1);
|
||||||
|
factory.Clients[0].IsConnected.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Repeat_read_reuses_connection()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "GVL.X", TwinCATDataType.DInt));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 1 } };
|
||||||
|
|
||||||
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||||
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||||
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||||
|
|
||||||
|
// One client, one connect — subsequent calls reuse the connected client.
|
||||||
|
factory.Clients.Count.ShouldBe(1);
|
||||||
|
factory.Clients[0].ConnectCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Read_with_ADS_error_maps_via_status_mapper()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new TwinCATTagDefinition("Ghost", "ads://5.23.91.23.1.1:851", "MAIN.Missing", TwinCATDataType.DInt));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () =>
|
||||||
|
{
|
||||||
|
var c = new FakeTwinCATClient();
|
||||||
|
c.ReadStatuses["MAIN.Missing"] = TwinCATStatusMapper.BadNodeIdUnknown;
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Read_exception_surfaces_BadCommunicationError()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () => new FakeTwinCATClient { ThrowOnRead = true };
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
|
||||||
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Connect_failure_surfaces_BadCommunicationError_and_disposes_client()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () => new FakeTwinCATClient { ThrowOnConnect = true };
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
|
||||||
|
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Batched_reads_preserve_order()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
|
||||||
|
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.Real),
|
||||||
|
new TwinCATTagDefinition("C", "ads://5.23.91.23.1.1:851", "MAIN.C", TwinCATDataType.String));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () => new FakeTwinCATClient
|
||||||
|
{
|
||||||
|
Values =
|
||||||
|
{
|
||||||
|
["MAIN.A"] = 1,
|
||||||
|
["MAIN.B"] = 3.14f,
|
||||||
|
["MAIN.C"] = "hello",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||||
|
snapshots[0].Value.ShouldBe(1);
|
||||||
|
snapshots[1].Value.ShouldBe(3.14f);
|
||||||
|
snapshots[2].Value.ShouldBe("hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Write ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Non_writable_tag_rejected_with_BadNotWritable()
|
||||||
|
{
|
||||||
|
var (drv, _) = NewDriver(
|
||||||
|
new TwinCATTagDefinition("RO", "ads://5.23.91.23.1.1:851", "MAIN.RO", TwinCATDataType.DInt, Writable: false));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("RO", 1)], CancellationToken.None);
|
||||||
|
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Successful_write_logs_symbol_type_value()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("Speed", 4200)], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
||||||
|
var write = factory.Clients[0].WriteLog.Single();
|
||||||
|
write.symbol.ShouldBe("MAIN.Speed");
|
||||||
|
write.type.ShouldBe(TwinCATDataType.DInt);
|
||||||
|
write.value.ShouldBe(4200);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Write_with_ADS_error_surfaces_mapped_status()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () =>
|
||||||
|
{
|
||||||
|
var c = new FakeTwinCATClient();
|
||||||
|
c.WriteStatuses["MAIN.X"] = TwinCATStatusMapper.BadNotWritable;
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("X", 1)], CancellationToken.None);
|
||||||
|
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Write_exception_surfaces_BadCommunicationError()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () => new FakeTwinCATClient { ThrowOnWrite = true };
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("X", 1)], CancellationToken.None);
|
||||||
|
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Batch_write_preserves_order_across_outcomes()
|
||||||
|
{
|
||||||
|
var factory = new FakeTwinCATClientFactory();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
|
||||||
|
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt, Writable: false),
|
||||||
|
],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[
|
||||||
|
new WriteRequest("A", 1),
|
||||||
|
new WriteRequest("B", 2),
|
||||||
|
new WriteRequest("Unknown", 3),
|
||||||
|
], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Count.ShouldBe(3);
|
||||||
|
results[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
||||||
|
results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
|
||||||
|
results[2].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancellation_propagates()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () => new FakeTwinCATClient
|
||||||
|
{
|
||||||
|
ThrowOnRead = true,
|
||||||
|
Exception = new OperationCanceledException(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(
|
||||||
|
() => drv.ReadAsync(["X"], CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShutdownAsync_disposes_client()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } };
|
||||||
|
|
||||||
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user