Compare commits
14 Commits
ablegacy-p
...
focas-pr2-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2c7fda5f5 | ||
| c13fe8f587 | |||
|
|
285799a954 | ||
| 9da578d5a5 | |||
|
|
6c5b202910 | ||
| a0112ddb43 | |||
|
|
aeb28cc8e7 | ||
| 2d5aaf1eda | |||
|
|
28e3470300 | ||
| bffac4db65 | |||
|
|
cd2c0bcadd | ||
| 7fdf4e5618 | |||
|
|
400fc6242c | ||
| 4438fdd7b1 |
@@ -12,6 +12,8 @@
|
||||
<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.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.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.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.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
@@ -33,6 +35,8 @@
|
||||
<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.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.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.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.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"/>
|
||||
|
||||
@@ -8,15 +8,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// <see cref="IDriver"/> only at PR 1 time; read / write / discovery / subscribe / probe /
|
||||
/// host-resolver capabilities ship in PRs 2 and 3.
|
||||
/// </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 string _driverInstanceId;
|
||||
private readonly IAbLegacyTagFactory _tagFactory;
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId,
|
||||
IAbLegacyTagFactory? tagFactory = null)
|
||||
{
|
||||
@@ -24,6 +29,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable,
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_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;
|
||||
@@ -43,6 +52,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable,
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||||
}
|
||||
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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -59,13 +79,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable,
|
||||
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();
|
||||
_tagsByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
@@ -194,6 +220,117 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable,
|
||||
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(
|
||||
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
||||
{
|
||||
@@ -237,6 +374,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable,
|
||||
public Dictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
|
||||
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()
|
||||
{
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
|
||||
95
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs
Normal file
95
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
|
||||
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
|
||||
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
|
||||
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
||||
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
||||
/// </remarks>
|
||||
public sealed record FocasAddress(
|
||||
FocasAreaKind Kind,
|
||||
string? PmcLetter,
|
||||
int Number,
|
||||
int? BitIndex)
|
||||
{
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => BitIndex is null
|
||||
? $"{PmcLetter}{Number}"
|
||||
: $"{PmcLetter}{Number}.{BitIndex}",
|
||||
FocasAreaKind.Parameter => BitIndex is null
|
||||
? $"PARAM:{Number}"
|
||||
: $"PARAM:{Number}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
|
||||
public static FocasAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
if (src.StartsWith("PARAM:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["PARAM:".Length..], FocasAreaKind.Parameter, bitSeparator: '/');
|
||||
|
||||
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
||||
|
||||
// PMC path: letter + digits + optional .bit
|
||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||
var letter = src[0..1].ToUpperInvariant();
|
||||
if (!IsValidPmcLetter(letter)) return null;
|
||||
|
||||
var remainder = src[1..];
|
||||
int? bit = null;
|
||||
var dotIdx = remainder.IndexOf('.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(remainder[(dotIdx + 1)..], out var bitValue) || bitValue is < 0 or > 7)
|
||||
return null;
|
||||
bit = bitValue;
|
||||
remainder = remainder[..dotIdx];
|
||||
}
|
||||
if (!int.TryParse(remainder, out var number) || number < 0) return null;
|
||||
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
|
||||
}
|
||||
|
||||
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
||||
{
|
||||
int? bit = null;
|
||||
if (bitSeparator is char sep)
|
||||
{
|
||||
var slashIdx = body.IndexOf(sep);
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(body[(slashIdx + 1)..], out var bitValue) || bitValue is < 0 or > 31)
|
||||
return null;
|
||||
bit = bitValue;
|
||||
body = body[..slashIdx];
|
||||
}
|
||||
}
|
||||
if (!int.TryParse(body, out var number) || number < 0) return null;
|
||||
return new FocasAddress(kind, PmcLetter: null, number, bit);
|
||||
}
|
||||
|
||||
private static bool IsValidPmcLetter(string letter) => letter switch
|
||||
{
|
||||
"X" or "Y" or "F" or "G" or "R" or "D" or "C" or "K" or "A" or "E" or "T" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Addressing-space kinds the driver understands.</summary>
|
||||
public enum FocasAreaKind
|
||||
{
|
||||
Pmc,
|
||||
Parameter,
|
||||
Macro,
|
||||
}
|
||||
39
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDataType.cs
Normal file
39
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDataType.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS atomic data types. Narrower than Logix/IEC — FANUC CNCs expose mostly integer +
|
||||
/// floating-point data with no UDT concept; macro variables are double-precision floats
|
||||
/// and PMC reads return byte / signed word / signed dword.
|
||||
/// </summary>
|
||||
public enum FocasDataType
|
||||
{
|
||||
/// <summary>Single bit (PMC bit, or bit within a CNC parameter).</summary>
|
||||
Bit,
|
||||
/// <summary>8-bit signed byte (PMC 1-byte read).</summary>
|
||||
Byte,
|
||||
/// <summary>16-bit signed word (PMC 2-byte read, or CNC parameter as short).</summary>
|
||||
Int16,
|
||||
/// <summary>32-bit signed int (PMC 4-byte read, or CNC parameter as int).</summary>
|
||||
Int32,
|
||||
/// <summary>32-bit IEEE-754 float (rare; some CNC macro variables).</summary>
|
||||
Float32,
|
||||
/// <summary>64-bit IEEE-754 double (most macro variables are double-precision).</summary>
|
||||
Float64,
|
||||
/// <summary>ASCII string (alarm text, parameter names, some PMC string areas).</summary>
|
||||
String,
|
||||
}
|
||||
|
||||
public static class FocasDataTypeExtensions
|
||||
{
|
||||
public static DriverDataType ToDriverDataType(this FocasDataType t) => t switch
|
||||
{
|
||||
FocasDataType.Bit => DriverDataType.Boolean,
|
||||
FocasDataType.Byte or FocasDataType.Int16 or FocasDataType.Int32 => DriverDataType.Int32,
|
||||
FocasDataType.Float32 => DriverDataType.Float32,
|
||||
FocasDataType.Float64 => DriverDataType.Float64,
|
||||
FocasDataType.String => DriverDataType.String,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
224
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
Normal file
224
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS driver for Fanuc CNC controllers (FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / Series
|
||||
/// 35i / Power Mate i). Talks to the CNC via the Fanuc FOCAS/2 FWLIB protocol through an
|
||||
/// <see cref="IFocasClient"/> the deployment supplies — FWLIB itself is Fanuc-proprietary
|
||||
/// and cannot be redistributed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PR 1 ships <see cref="IDriver"/> only; read / write / discover / subscribe / probe / host-
|
||||
/// resolver capabilities land in PRs 2 and 3. The <see cref="IFocasClient"/> abstraction
|
||||
/// shipped here lets PR 2 onward stay license-clean — all tests run against a fake client
|
||||
/// + the default <see cref="UnimplementedFocasClientFactory"/> makes misconfigured servers
|
||||
/// fail fast.
|
||||
/// </remarks>
|
||||
public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly FocasDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IFocasClientFactory _clientFactory;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
||||
IFocasClientFactory? clientFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_clientFactory = clientFactory ?? new FwlibFocasClientFactory();
|
||||
}
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
public string DriverType => "FOCAS";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var addr = FocasHostAddress.TryParse(device.HostAddress)
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{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, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = FocasAddress.TryParse(def.Address)
|
||||
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||
if (status == FocasStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
else
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"FOCAS status 0x{status:X8} reading {reference}");
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.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(FocasStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!def.Writable)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = FocasAddress.TryParse(def.Address)
|
||||
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(status);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IFocasClient> 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(FocasHostAddress parsedAddress, FocasDeviceOptions options)
|
||||
{
|
||||
public FocasHostAddress ParsedAddress { get; } = parsedAddress;
|
||||
public FocasDeviceOptions Options { get; } = options;
|
||||
public IFocasClient? Client { get; set; }
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
Client = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs
Normal file
38
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS driver configuration. One instance supports N CNC devices. Per plan decision #144
|
||||
/// each device gets its own <c>(DriverInstanceId, HostAddress)</c> bulkhead key at the
|
||||
/// Phase 6.1 resilience layer.
|
||||
/// </summary>
|
||||
public sealed class FocasDriverOptions
|
||||
{
|
||||
public IReadOnlyList<FocasDeviceOptions> Devices { get; init; } = [];
|
||||
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
||||
public FocasProbeOptions Probe { get; init; } = new();
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
public sealed record FocasDeviceOptions(
|
||||
string HostAddress,
|
||||
string? DeviceName = null);
|
||||
|
||||
/// <summary>
|
||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
|
||||
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c>.
|
||||
/// </summary>
|
||||
public sealed record FocasTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string Address,
|
||||
FocasDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
|
||||
public sealed class FocasProbeOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
41
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasHostAddress.cs
Normal file
41
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasHostAddress.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed FOCAS target address — IP + TCP port. Canonical <c>focas://{ip}[:{port}]</c>.
|
||||
/// Default port 8193 (Fanuc-reserved FOCAS Ethernet port).
|
||||
/// </summary>
|
||||
public sealed record FocasHostAddress(string Host, int Port)
|
||||
{
|
||||
/// <summary>Fanuc-reserved TCP port for FOCAS Ethernet.</summary>
|
||||
public const int DefaultPort = 8193;
|
||||
|
||||
public override string ToString() => Port == DefaultPort
|
||||
? $"focas://{Host}"
|
||||
: $"focas://{Host}:{Port}";
|
||||
|
||||
public static FocasHostAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
const string prefix = "focas://";
|
||||
if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var body = value[prefix.Length..];
|
||||
if (string.IsNullOrEmpty(body)) return null;
|
||||
|
||||
var colonIdx = body.LastIndexOf(':');
|
||||
string host;
|
||||
var port = DefaultPort;
|
||||
if (colonIdx >= 0)
|
||||
{
|
||||
host = body[..colonIdx];
|
||||
if (!int.TryParse(body[(colonIdx + 1)..], out port) || port is <= 0 or > 65535)
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
host = body;
|
||||
}
|
||||
if (string.IsNullOrEmpty(host)) return null;
|
||||
return new FocasHostAddress(host, port);
|
||||
}
|
||||
}
|
||||
48
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs
Normal file
48
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Maps FOCAS / FWLIB return codes to OPC UA StatusCodes. The FWLIB C API uses an
|
||||
/// <c>EW_*</c> constant family per the Fanuc FOCAS/1 and FOCAS/2 documentation
|
||||
/// (<c>EW_OK = 0</c>, <c>EW_NUMBER</c>, <c>EW_SOCKET</c>, etc.). Mirrors the shape of the
|
||||
/// AbCip / TwinCAT mappers so Admin UI status displays stay uniform across drivers.
|
||||
/// </summary>
|
||||
public static class FocasStatusMapper
|
||||
{
|
||||
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 common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
|
||||
/// numeric conventions (EW_OK=0, EW_FUNC=1, EW_NUMBER=3, EW_LENGTH=4, EW_ATTRIB=7,
|
||||
/// EW_DATA=8, EW_NOOPT=6, EW_PROT=5, EW_OVRFLOW=2, EW_PARITY=9, EW_PASSWD=11,
|
||||
/// EW_BUSY=-1, EW_HANDLE=-8, EW_VERSION=-9, EW_UNEXP=-10, EW_SOCKET=-16).
|
||||
/// </summary>
|
||||
public static uint MapFocasReturn(int ret) => ret switch
|
||||
{
|
||||
0 => Good,
|
||||
1 => BadNotSupported, // EW_FUNC — CNC does not support this function
|
||||
2 => BadOutOfRange, // EW_OVRFLOW
|
||||
3 => BadOutOfRange, // EW_NUMBER
|
||||
4 => BadOutOfRange, // EW_LENGTH
|
||||
5 => BadNotWritable, // EW_PROT
|
||||
6 => BadNotSupported, // EW_NOOPT — optional CNC feature missing
|
||||
7 => BadTypeMismatch, // EW_ATTRIB
|
||||
8 => BadNodeIdUnknown, // EW_DATA — invalid data address
|
||||
9 => BadCommunicationError, // EW_PARITY
|
||||
11 => BadNotWritable, // EW_PASSWD
|
||||
-1 => BadDeviceFailure, // EW_BUSY
|
||||
-8 => BadInternalError, // EW_HANDLE — CNC handle not available
|
||||
-9 => BadNotSupported, // EW_VERSION — FWLIB vs CNC version mismatch
|
||||
-10 => BadCommunicationError, // EW_UNEXP
|
||||
-16 => BadCommunicationError, // EW_SOCKET
|
||||
_ => BadCommunicationError,
|
||||
};
|
||||
}
|
||||
269
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs
Normal file
269
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IFocasClient"/> implementation backed by Fanuc's licensed
|
||||
/// <c>Fwlib32.dll</c> via <see cref="FwlibNative"/> P/Invoke. The DLL is NOT shipped with
|
||||
/// OtOpcUa; the deployment places it next to the server executable or on <c>PATH</c>
|
||||
/// (per Fanuc licensing — see <c>docs/v2/focas-deployment.md</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Construction is licence-safe — .NET P/Invoke is lazy, so instantiating this class
|
||||
/// does NOT load <c>Fwlib32.dll</c>. The DLL only loads on the first wire call (Connect /
|
||||
/// Read / Write / Probe). When missing, those calls throw <see cref="DllNotFoundException"/>
|
||||
/// which the driver surfaces as <c>BadCommunicationError</c> through the normal exception
|
||||
/// mapping.</para>
|
||||
///
|
||||
/// <para>Session-scoped handle — <c>cnc_allclibhndl3</c> opens one FWLIB handle per CNC;
|
||||
/// all PMC / parameter / macro reads on that device go through the same handle. Dispose
|
||||
/// calls <c>cnc_freelibhndl</c>.</para>
|
||||
/// </remarks>
|
||||
internal sealed class FwlibFocasClient : IFocasClient
|
||||
{
|
||||
private ushort _handle;
|
||||
private bool _connected;
|
||||
|
||||
public bool IsConnected => _connected;
|
||||
|
||||
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connected) return Task.CompletedTask;
|
||||
|
||||
var timeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds);
|
||||
var ret = FwlibNative.AllcLibHndl3(address.Host, (ushort)address.Port, timeoutMs, out var handle);
|
||||
if (ret != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"FWLIB cnc_allclibhndl3 failed with EW_{ret} connecting to {address}.");
|
||||
_handle = handle;
|
||||
_connected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
|
||||
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
|
||||
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
|
||||
_ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
|
||||
};
|
||||
}
|
||||
|
||||
public Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(FocasStatusMapper.BadCommunicationError);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => Task.FromResult(WritePmc(address, type, value)),
|
||||
FocasAreaKind.Parameter => Task.FromResult(WriteParameter(address, type, value)),
|
||||
FocasAreaKind.Macro => Task.FromResult(WriteMacro(address, value)),
|
||||
_ => Task.FromResult(FocasStatusMapper.BadNotSupported),
|
||||
};
|
||||
}
|
||||
|
||||
public Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(false);
|
||||
var buf = new FwlibNative.ODBST();
|
||||
var ret = FwlibNative.StatInfo(_handle, ref buf);
|
||||
return Task.FromResult(ret == 0);
|
||||
}
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "")
|
||||
?? throw new InvalidOperationException($"Unknown PMC letter '{address.PmcLetter}'.");
|
||||
var dataType = FocasPmcDataType.FromFocasDataType(type);
|
||||
var length = PmcReadLength(type);
|
||||
|
||||
var buf = new FwlibNative.IODBPMC { Data = new byte[40] };
|
||||
var ret = FwlibNative.PmcRdPmcRng(
|
||||
_handle, addrType, dataType,
|
||||
(ushort)address.Number, (ushort)address.Number, (ushort)length, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
var value = type switch
|
||||
{
|
||||
FocasDataType.Bit => ExtractBit(buf.Data[0], address.BitIndex ?? 0),
|
||||
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
|
||||
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
|
||||
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data),
|
||||
FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data),
|
||||
_ => (object)buf.Data[0],
|
||||
};
|
||||
return (value, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private uint WritePmc(FocasAddress address, FocasDataType type, object? value)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
|
||||
var dataType = FocasPmcDataType.FromFocasDataType(type);
|
||||
var length = PmcWriteLength(type);
|
||||
|
||||
var buf = new FwlibNative.IODBPMC
|
||||
{
|
||||
TypeA = addrType,
|
||||
TypeD = dataType,
|
||||
DatanoS = (ushort)address.Number,
|
||||
DatanoE = (ushort)address.Number,
|
||||
Data = new byte[40],
|
||||
};
|
||||
EncodePmcValue(buf.Data, type, value, address.BitIndex);
|
||||
|
||||
var ret = FwlibNative.PmcWrPmcRng(_handle, (ushort)length, ref buf);
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
private (object? value, uint status) ReadParameter(FocasAddress address, FocasDataType type)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var length = ParamReadLength(type);
|
||||
var ret = FwlibNative.RdParam(_handle, (ushort)address.Number, axis: 0, (short)length, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
var value = type switch
|
||||
{
|
||||
FocasDataType.Bit when address.BitIndex is int bit => ExtractBit(buf.Data[0], bit),
|
||||
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
|
||||
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
|
||||
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
_ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
};
|
||||
return (value, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private uint WriteParameter(FocasAddress address, FocasDataType type, object? value)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD
|
||||
{
|
||||
Datano = (short)address.Number,
|
||||
Type = 0,
|
||||
Data = new byte[32],
|
||||
};
|
||||
var length = ParamReadLength(type);
|
||||
EncodeParamValue(buf.Data, type, value);
|
||||
var ret = FwlibNative.WrParam(_handle, (short)length, ref buf);
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
private (object? value, uint status) ReadMacro(FocasAddress address)
|
||||
{
|
||||
var buf = new FwlibNative.ODBM();
|
||||
var ret = FwlibNative.RdMacro(_handle, (short)address.Number, length: 8, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
// Macro value = mcr_val / 10^dec_val. Convert to double so callers get the correct
|
||||
// scaled value regardless of the decimal-point count the CNC reports.
|
||||
var scaled = buf.McrVal / Math.Pow(10.0, buf.DecVal);
|
||||
return (scaled, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private uint WriteMacro(FocasAddress address, object? value)
|
||||
{
|
||||
// Write as integer + 0 decimal places — callers that need decimal precision can extend
|
||||
// this via a future WriteMacroScaled overload. Consistent with what most HMIs do today.
|
||||
var intValue = Convert.ToInt32(value);
|
||||
var ret = FwlibNative.WrMacro(_handle, (short)address.Number, length: 8, intValue, decimalPointCount: 0);
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_connected)
|
||||
{
|
||||
try { FwlibNative.FreeLibHndl(_handle); } catch { }
|
||||
_connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static int PmcReadLength(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => 8 + 1, // 8-byte header + 1 byte payload
|
||||
FocasDataType.Int16 => 8 + 2,
|
||||
FocasDataType.Int32 => 8 + 4,
|
||||
FocasDataType.Float32 => 8 + 4,
|
||||
FocasDataType.Float64 => 8 + 8,
|
||||
_ => 8 + 1,
|
||||
};
|
||||
|
||||
private static int PmcWriteLength(FocasDataType type) => PmcReadLength(type);
|
||||
private static int ParamReadLength(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => 4 + 1,
|
||||
FocasDataType.Int16 => 4 + 2,
|
||||
FocasDataType.Int32 => 4 + 4,
|
||||
_ => 4 + 4,
|
||||
};
|
||||
|
||||
private static bool ExtractBit(byte word, int bit) => (word & (1 << bit)) != 0;
|
||||
|
||||
internal static void EncodePmcValue(byte[] data, FocasDataType type, object? value, int? bitIndex)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case FocasDataType.Bit:
|
||||
// Bit-in-byte write is a read-modify-write at the PMC level — the underlying
|
||||
// pmc_wrpmcrng takes a byte payload, so caller must set the bit on a byte they
|
||||
// just read. This path is flagged for the follow-up RMW work in task #181.
|
||||
throw new NotSupportedException(
|
||||
"FOCAS Bit writes require read-modify-write; tracked in task #181.");
|
||||
case FocasDataType.Byte:
|
||||
data[0] = (byte)(sbyte)Convert.ToSByte(value);
|
||||
break;
|
||||
case FocasDataType.Int16:
|
||||
BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value));
|
||||
break;
|
||||
case FocasDataType.Int32:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
|
||||
break;
|
||||
case FocasDataType.Float32:
|
||||
BinaryPrimitives.WriteSingleLittleEndian(data, Convert.ToSingle(value));
|
||||
break;
|
||||
case FocasDataType.Float64:
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(data, Convert.ToDouble(value));
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"FocasDataType {type} not writable via PMC.");
|
||||
}
|
||||
_ = bitIndex; // bit-in-byte handled above
|
||||
}
|
||||
|
||||
internal static void EncodeParamValue(byte[] data, FocasDataType type, object? value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case FocasDataType.Byte:
|
||||
data[0] = (byte)(sbyte)Convert.ToSByte(value);
|
||||
break;
|
||||
case FocasDataType.Int16:
|
||||
BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value));
|
||||
break;
|
||||
case FocasDataType.Int32:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
|
||||
break;
|
||||
default:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default <see cref="IFocasClientFactory"/> — produces a fresh <see cref="FwlibFocasClient"/> per device.</summary>
|
||||
public sealed class FwlibFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => new FwlibFocasClient();
|
||||
}
|
||||
190
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs
Normal file
190
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// P/Invoke surface for Fanuc FWLIB (<c>Fwlib32.dll</c>). Declarations extracted from
|
||||
/// <c>fwlib32.h</c> in the strangesast/fwlib repo; the licensed DLL itself is NOT shipped
|
||||
/// with OtOpcUa — the deployment places <c>Fwlib32.dll</c> next to the server executable
|
||||
/// or on <c>PATH</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately narrow — only the calls <see cref="FwlibFocasClient"/> actually makes.
|
||||
/// FOCAS has 800+ functions in <c>fwlib32.h</c>; pulling in every one would bloat the
|
||||
/// P/Invoke surface + signal more coverage than this driver provides. Expand as capabilities
|
||||
/// are added.
|
||||
/// </remarks>
|
||||
internal static class FwlibNative
|
||||
{
|
||||
private const string Library = "Fwlib32.dll";
|
||||
|
||||
// ---- Handle lifetime ----
|
||||
|
||||
/// <summary>Open an Ethernet FWLIB handle. Returns EW_OK (0) on success; handle written out.</summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_allclibhndl3", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
public static extern short AllcLibHndl3(
|
||||
[MarshalAs(UnmanagedType.LPStr)] string ipaddr,
|
||||
ushort port,
|
||||
int timeout,
|
||||
out ushort handle);
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_freelibhndl", ExactSpelling = true)]
|
||||
public static extern short FreeLibHndl(ushort handle);
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
/// <summary>PMC range read. <paramref name="addrType"/> is the ADR_* enum; <paramref name="dataType"/> is 0 byte / 1 word / 2 long.</summary>
|
||||
[DllImport(Library, EntryPoint = "pmc_rdpmcrng", ExactSpelling = true)]
|
||||
public static extern short PmcRdPmcRng(
|
||||
ushort handle,
|
||||
short addrType,
|
||||
short dataType,
|
||||
ushort startNumber,
|
||||
ushort endNumber,
|
||||
ushort length,
|
||||
ref IODBPMC buffer);
|
||||
|
||||
[DllImport(Library, EntryPoint = "pmc_wrpmcrng", ExactSpelling = true)]
|
||||
public static extern short PmcWrPmcRng(
|
||||
ushort handle,
|
||||
ushort length,
|
||||
ref IODBPMC buffer);
|
||||
|
||||
// ---- Parameters ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_rdparam", ExactSpelling = true)]
|
||||
public static extern short RdParam(
|
||||
ushort handle,
|
||||
ushort number,
|
||||
short axis,
|
||||
short length,
|
||||
ref IODBPSD buffer);
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_wrparam", ExactSpelling = true)]
|
||||
public static extern short WrParam(
|
||||
ushort handle,
|
||||
short length,
|
||||
ref IODBPSD buffer);
|
||||
|
||||
// ---- Macro variables ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)]
|
||||
public static extern short RdMacro(
|
||||
ushort handle,
|
||||
short number,
|
||||
short length,
|
||||
ref ODBM buffer);
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_wrmacro", ExactSpelling = true)]
|
||||
public static extern short WrMacro(
|
||||
ushort handle,
|
||||
short number,
|
||||
short length,
|
||||
int macroValue,
|
||||
short decimalPointCount);
|
||||
|
||||
// ---- Status ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
|
||||
public static extern short StatInfo(ushort handle, ref ODBST buffer);
|
||||
|
||||
// ---- Structs ----
|
||||
|
||||
/// <summary>
|
||||
/// IODBPMC — PMC range I/O buffer. 8-byte header + 40-byte union. We marshal the union
|
||||
/// as a fixed byte buffer + interpret per <see cref="FocasDataType"/> on the managed side.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBPMC
|
||||
{
|
||||
public short TypeA;
|
||||
public short TypeD;
|
||||
public ushort DatanoS;
|
||||
public ushort DatanoE;
|
||||
// 40-byte union: cdata[5] / idata[5] / ldata[5] / fdata[5] / dbdata[5] — dbdata is the widest.
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 40)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBPSD — CNC parameter I/O buffer. Axis-aware; for non-axis parameters pass axis=0.
|
||||
/// Union payload is bytes / shorts / longs — we marshal 32 bytes as the widest slot.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBPSD
|
||||
{
|
||||
public short Datano;
|
||||
public short Type; // axis index (0 for non-axis)
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>ODBM — macro variable read buffer. Value = <c>McrVal / 10^DecVal</c>.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBM
|
||||
{
|
||||
public short Datano;
|
||||
public short Dummy;
|
||||
public int McrVal; // long in C; 32-bit signed
|
||||
public short DecVal; // decimal-point count
|
||||
}
|
||||
|
||||
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBST
|
||||
{
|
||||
public short Dummy;
|
||||
public short TmMode;
|
||||
public short Aut;
|
||||
public short Run;
|
||||
public short Motion;
|
||||
public short Mstb;
|
||||
public short Emergency;
|
||||
public short Alarm;
|
||||
public short Edit;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PMC address-letter → FOCAS <c>ADR_*</c> numeric code. Per Fanuc FOCAS/2 spec the codes
|
||||
/// are: G=0, F=1, Y=2, X=3, A=4, R=5, T=6, K=7, C=8, D=9, E=10. Exposed internally +
|
||||
/// tested so the FwlibFocasClient translation is verifiable without the DLL loaded.
|
||||
/// </summary>
|
||||
internal static class FocasPmcAddrType
|
||||
{
|
||||
public static short? FromLetter(string letter) => letter.ToUpperInvariant() switch
|
||||
{
|
||||
"G" => 0,
|
||||
"F" => 1,
|
||||
"Y" => 2,
|
||||
"X" => 3,
|
||||
"A" => 4,
|
||||
"R" => 5,
|
||||
"T" => 6,
|
||||
"K" => 7,
|
||||
"C" => 8,
|
||||
"D" => 9,
|
||||
"E" => 10,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>PMC data-type numeric codes per FOCAS/2: 0 = byte, 1 = word, 2 = long, 4 = float, 5 = double.</summary>
|
||||
internal static class FocasPmcDataType
|
||||
{
|
||||
public const short Byte = 0;
|
||||
public const short Word = 1;
|
||||
public const short Long = 2;
|
||||
public const short Float = 4;
|
||||
public const short Double = 5;
|
||||
|
||||
public static short FromFocasDataType(FocasDataType t) => t switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => Byte,
|
||||
FocasDataType.Int16 => Word,
|
||||
FocasDataType.Int32 => Long,
|
||||
FocasDataType.Float32 => Float,
|
||||
FocasDataType.Float64 => Double,
|
||||
_ => Byte,
|
||||
};
|
||||
}
|
||||
70
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
Normal file
70
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-layer abstraction over one FOCAS session to a CNC. The driver holds one per
|
||||
/// configured device; lifetime matches the device.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>No default wire implementation ships with this assembly.</b> FWLIB
|
||||
/// (<c>Fwlib32.dll</c>) is Fanuc-proprietary and requires a valid customer license — it
|
||||
/// cannot legally be redistributed. The deployment team supplies an
|
||||
/// <see cref="IFocasClientFactory"/> that wraps the licensed <c>Fwlib32.dll</c> via
|
||||
/// P/Invoke and registers it at server startup.</para>
|
||||
///
|
||||
/// <para>The default <see cref="UnimplementedFocasClientFactory"/> throws with a pointer at
|
||||
/// the deployment docs so misconfigured servers fail fast with a clear error rather than
|
||||
/// mysteriously hanging.</para>
|
||||
/// </remarks>
|
||||
public interface IFocasClient : IDisposable
|
||||
{
|
||||
/// <summary>Open the FWLIB handle + TCP session. Idempotent.</summary>
|
||||
Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Read the value at <paramref name="address"/> in the requested
|
||||
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped
|
||||
/// through <see cref="FocasStatusMapper"/>.
|
||||
/// </summary>
|
||||
Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address,
|
||||
FocasDataType type,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Write <paramref name="value"/> to <paramref name="address"/>. Returns the mapped
|
||||
/// OPC UA status (0 = Good).
|
||||
/// </summary>
|
||||
Task<uint> WriteAsync(
|
||||
FocasAddress address,
|
||||
FocasDataType type,
|
||||
object? value,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cheap health probe — e.g. <c>cnc_rdcncstat</c>. Returns <c>true</c> when the CNC
|
||||
/// responds with any valid status.
|
||||
/// </summary>
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||
public interface IFocasClientFactory
|
||||
{
|
||||
IFocasClient Create();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default factory that throws at construction time — the deployment must register a real
|
||||
/// factory. Keeps the driver assembly licence-clean while still allowing the skeleton to
|
||||
/// compile + the abstraction tests to run.
|
||||
/// </summary>
|
||||
public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => throw new NotSupportedException(
|
||||
"FOCAS driver has no wire client configured. Register a real IFocasClientFactory at " +
|
||||
"server startup wrapping the licensed Fwlib32.dll — see docs/v2/focas-deployment.md. " +
|
||||
"Fanuc licensing forbids shipping Fwlib32.dll in the OtOpcUa package.");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<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.FOCAS</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.FOCAS</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
No NuGet reference to a FOCAS library — FWLIB is Fanuc-proprietary and the licensed
|
||||
Fwlib32.dll cannot be redistributed. The deployment side supplies an IFocasClient
|
||||
implementation that P/Invokes against whatever Fwlib32.dll the customer has licensed.
|
||||
Driver.FOCAS.IntegrationTests in a separate repo can wire in the real binary.
|
||||
Follow-up task #193 tracks the real-client reference implementation that customers may
|
||||
drop in privately.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
233
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
Normal file
233
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
using System.Collections.Concurrent;
|
||||
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();
|
||||
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
|
||||
|
||||
public AdsTwinCATClient()
|
||||
{
|
||||
_client.AdsNotificationEx += OnAdsNotificationEx;
|
||||
}
|
||||
|
||||
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 async Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
TimeSpan cycleTime,
|
||||
Action<string, object?> onChange,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var clrType = MapToClrType(type);
|
||||
// NotificationSettings takes cycle + max-delay in 100ns units. AdsTransMode.OnChange
|
||||
// fires when the value differs; OnCycle fires every cycle. OnChange is the right default
|
||||
// for OPC UA data-change semantics — the PLC already has the best view of "has this
|
||||
// changed" so we let it decide.
|
||||
var cycleTicks = (uint)Math.Max(1, cycleTime.Ticks / TimeSpan.TicksPerMillisecond * 10_000);
|
||||
var settings = new NotificationSettings(AdsTransMode.OnChange, (int)cycleTicks, 0);
|
||||
|
||||
// AddDeviceNotificationExAsync returns Task<ResultHandle>; AdsNotificationEx fires
|
||||
// with the handle as part of the event args so we use the handle as the correlation
|
||||
// key into _notifications.
|
||||
var result = await _client.AddDeviceNotificationExAsync(
|
||||
symbolPath, settings, userData: null, clrType, args: null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
throw new InvalidOperationException(
|
||||
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}");
|
||||
|
||||
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle);
|
||||
_notifications[result.Handle] = reg;
|
||||
return reg;
|
||||
}
|
||||
|
||||
private void OnAdsNotificationEx(object? sender, AdsNotificationExEventArgs args)
|
||||
{
|
||||
if (!_notifications.TryGetValue(args.Handle, out var reg)) return;
|
||||
var value = args.Value;
|
||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ }
|
||||
}
|
||||
|
||||
internal async Task DeleteNotificationAsync(uint handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_notifications.TryRemove(handle, out _);
|
||||
try { await _client.DeleteDeviceNotificationAsync(handle, cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort tear-down; target may already be gone */ }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.AdsNotificationEx -= OnAdsNotificationEx;
|
||||
_notifications.Clear();
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
private sealed class NotificationRegistration(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
Action<string, object?> onChange,
|
||||
AdsTwinCATClient owner,
|
||||
uint handle) : ITwinCATNotificationHandle
|
||||
{
|
||||
public string SymbolPath { get; } = symbolPath;
|
||||
public TwinCATDataType Type { get; } = type;
|
||||
public int? BitIndex { get; } = bitIndex;
|
||||
public Action<string, object?> OnChange { get; } = onChange;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Fire-and-forget AMS call — caller has already committed to the tear-down.
|
||||
_ = owner.DeleteNotificationAsync(handle, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
78
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
Normal file
78
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
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>
|
||||
/// Register a cyclic / on-change ADS notification for a symbol. Returns a handle whose
|
||||
/// <see cref="IDisposable.Dispose"/> tears the notification down. Callback fires on the
|
||||
/// thread libplctag / AdsClient uses for notifications — consumers should marshal to
|
||||
/// their own scheduler before doing work of any size.
|
||||
/// </summary>
|
||||
/// <param name="symbolPath">ADS symbol path (e.g. <c>MAIN.bStart</c>).</param>
|
||||
/// <param name="type">Declared type; drives the native layout + callback value boxing.</param>
|
||||
/// <param name="bitIndex">For BOOL-within-word tags — the bit to extract from the parent word.</param>
|
||||
/// <param name="cycleTime">Minimum interval between change notifications (native-floor depends on target).</param>
|
||||
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
|
||||
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
|
||||
Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
TimeSpan cycleTime,
|
||||
Action<string, object?> onChange,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary>
|
||||
public interface ITwinCATNotificationHandle : IDisposable { }
|
||||
|
||||
/// <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,
|
||||
};
|
||||
}
|
||||
415
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
Normal file
415
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
Normal file
@@ -0,0 +1,415 @@
|
||||
using System.Collections.Concurrent;
|
||||
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, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly TwinCATDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly ITwinCATClientFactory _clientFactory;
|
||||
private readonly PollGroupEngine _poll;
|
||||
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 event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId,
|
||||
ITwinCATClientFactory? clientFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_clientFactory = clientFactory ?? new AdsTwinCATClientFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
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);
|
||||
}
|
||||
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 async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Native subs first — disposing the handles is cheap + lets the client close its
|
||||
// notifications before the AdsClient itself goes away.
|
||||
foreach (var sub in _nativeSubs.Values)
|
||||
foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
|
||||
_nativeSubs.Clear();
|
||||
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.DisposeClient();
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var root = builder.Folder("TwinCAT", "TwinCAT");
|
||||
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 (native ADS notifications with poll fallback) ----
|
||||
|
||||
private readonly ConcurrentDictionary<long, NativeSubscription> _nativeSubs = new();
|
||||
private long _nextNativeSubId;
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe via native ADS notifications when <see cref="TwinCATDriverOptions.UseNativeNotifications"/>
|
||||
/// is <c>true</c>, otherwise fall through to the shared <see cref="PollGroupEngine"/>.
|
||||
/// Native path registers one <see cref="ITwinCATNotificationHandle"/> per tag against the
|
||||
/// target's PLC runtime — the PLC pushes changes on its own cycle so we skip the poll
|
||||
/// loop entirely. Unsub path disposes the handles.
|
||||
/// </summary>
|
||||
public async Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.UseNativeNotifications)
|
||||
return _poll.Subscribe(fullReferences, publishingInterval);
|
||||
|
||||
var id = Interlocked.Increment(ref _nextNativeSubId);
|
||||
var handle = new NativeSubscriptionHandle(id);
|
||||
var registrations = new List<ITwinCATNotificationHandle>(fullReferences.Count);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var reference in fullReferences)
|
||||
{
|
||||
if (!_tagsByName.TryGetValue(reference, out var def)) continue;
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
|
||||
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var bitIndex = parsed?.BitIndex;
|
||||
|
||||
var reg = await client.AddNotificationAsync(
|
||||
symbolName, def.DataType, bitIndex, publishingInterval,
|
||||
(_, value) => OnDataChange?.Invoke(this,
|
||||
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
|
||||
value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
registrations.Add(reg);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// On any registration failure, tear down everything we got so far + rethrow. Leaves
|
||||
// the subscription in a clean "never existed" state rather than a half-registered
|
||||
// state the caller has to clean up.
|
||||
foreach (var r in registrations) { try { r.Dispose(); } catch { } }
|
||||
throw;
|
||||
}
|
||||
|
||||
_nativeSubs[id] = new NativeSubscription(handle, registrations);
|
||||
return handle;
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is NativeSubscriptionHandle native && _nativeSubs.TryRemove(native.Id, out var sub))
|
||||
{
|
||||
foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
_poll.Unsubscribe(handle);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record NativeSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"twincat-native-sub-{Id}";
|
||||
}
|
||||
|
||||
private sealed record NativeSubscription(
|
||||
NativeSubscriptionHandle Handle,
|
||||
IReadOnlyList<ITwinCATNotificationHandle> Registrations);
|
||||
|
||||
// ---- 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)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch
|
||||
{
|
||||
// Probe failure — EnsureConnectedAsync's connect-failure path already disposed
|
||||
// + cleared the client, so next tick will reconnect.
|
||||
}
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
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<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 object ProbeLock { get; } = new();
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
Client = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs
Normal file
53
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
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>
|
||||
/// When <c>true</c> (default), <c>SubscribeAsync</c> registers native ADS notifications
|
||||
/// via <c>AddDeviceNotificationExAsync</c> — the PLC pushes changes on its own cycle
|
||||
/// rather than the driver polling. Strictly better for latency + CPU when the target
|
||||
/// supports it (TC2 + TC3 PLC runtimes always do; some soft-PLC / third-party ADS
|
||||
/// implementations may not). When <c>false</c>, the driver falls through to the shared
|
||||
/// <see cref="Core.Abstractions.PollGroupEngine"/> — same semantics as the other
|
||||
/// libplctag-backed drivers. Set <c>false</c> for deployments where the AMS router has
|
||||
/// notification limits you can't raise.
|
||||
/// </summary>
|
||||
public bool UseNativeNotifications { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <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,69 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
internal class FakeFocasClient : IFocasClient
|
||||
{
|
||||
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 ProbeResult { get; set; } = true;
|
||||
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<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new();
|
||||
|
||||
public virtual Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ConnectCount++;
|
||||
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||
var key = address.Canonical;
|
||||
var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good;
|
||||
var value = Values.TryGetValue(key, out var v) ? v : null;
|
||||
return Task.FromResult((value, status));
|
||||
}
|
||||
|
||||
public virtual Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
WriteLog.Add((address, type, value));
|
||||
Values[address.Canonical] = value;
|
||||
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public List<FakeFocasClient> Clients { get; } = new();
|
||||
public Func<FakeFocasClient>? Customise { get; set; }
|
||||
|
||||
public IFocasClient Create()
|
||||
{
|
||||
var c = Customise?.Invoke() ?? new FakeFocasClient();
|
||||
Clients.Add(c);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasReadWriteTests
|
||||
{
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(params FocasTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { 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(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_PMC_read_returns_Good_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)5 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Run"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe((sbyte)5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parameter_read_routes_through_FocasAddress_Parameter_kind()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Accel", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["PARAM:1820"] = 1500 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Accel"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(1500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Macro_read_routes_through_FocasAddress_Macro_kind()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["MACRO:500"] = 3.14159 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["CustomVar"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(3.14159);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_reuses_connection()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
factory.Clients.Count.ShouldBe(1);
|
||||
factory.Clients[0].ConnectCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FOCAS_error_status_maps_via_status_mapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Ghost", "focas://10.0.0.5:8193", "R999", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.ReadStatuses["R999"] = FocasStatusMapper.BadNodeIdUnknown;
|
||||
return c;
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_exception_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { ThrowOnRead = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_failure_disposes_client_and_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { ThrowOnConnect = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batched_reads_preserve_order_across_areas()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32),
|
||||
new FocasTagDefinition("C", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
Values =
|
||||
{
|
||||
["R100"] = (sbyte)5,
|
||||
["PARAM:1820"] = 1500,
|
||||
["MACRO:500"] = 2.718,
|
||||
},
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
snapshots[0].Value.ShouldBe((sbyte)5);
|
||||
snapshots[1].Value.ShouldBe(1500);
|
||||
snapshots[2].Value.ShouldBe(2.718);
|
||||
}
|
||||
|
||||
// ---- Write ----
|
||||
|
||||
[Fact]
|
||||
public async Task Non_writable_tag_rejected_with_BadNotWritable()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new FocasTagDefinition("RO", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("RO", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_write_logs_address_type_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Speed", (short)1800)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
var write = factory.Clients[0].WriteLog.Single();
|
||||
write.addr.Canonical.ShouldBe("R100");
|
||||
write.type.ShouldBe(FocasDataType.Int16);
|
||||
write.value.ShouldBe((short)1800);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_status_code_maps_via_FocasStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Protected", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.WriteStatuses["R100"] = FocasStatusMapper.BadNotWritable;
|
||||
return c;
|
||||
};
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Protected", (sbyte)1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_write_preserves_order_across_outcomes()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", (sbyte)1),
|
||||
new WriteRequest("B", (sbyte)2),
|
||||
new WriteRequest("Unknown", (sbyte)3),
|
||||
], CancellationToken.None);
|
||||
|
||||
results[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
results[2].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
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 FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasScaffoldingTests
|
||||
{
|
||||
// ---- FocasHostAddress ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("focas://10.0.0.5:8193", "10.0.0.5", 8193)]
|
||||
[InlineData("focas://10.0.0.5", "10.0.0.5", 8193)] // default port
|
||||
[InlineData("focas://cnc-01.factory.internal:8193", "cnc-01.factory.internal", 8193)]
|
||||
[InlineData("focas://10.0.0.5:12345", "10.0.0.5", 12345)]
|
||||
[InlineData("FOCAS://10.0.0.5:8193", "10.0.0.5", 8193)] // case-insensitive scheme
|
||||
public void HostAddress_parses_valid(string input, string host, int port)
|
||||
{
|
||||
var parsed = FocasHostAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Host.ShouldBe(host);
|
||||
parsed.Port.ShouldBe(port);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("http://10.0.0.5/")]
|
||||
[InlineData("focas:10.0.0.5:8193")] // missing //
|
||||
[InlineData("focas://")] // empty body
|
||||
[InlineData("focas://10.0.0.5:0")] // port 0
|
||||
[InlineData("focas://10.0.0.5:65536")] // port out of range
|
||||
[InlineData("focas://10.0.0.5:abc")] // non-numeric port
|
||||
public void HostAddress_rejects_invalid(string? input)
|
||||
{
|
||||
FocasHostAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostAddress_ToString_strips_default_port()
|
||||
{
|
||||
new FocasHostAddress("10.0.0.5", 8193).ToString().ShouldBe("focas://10.0.0.5");
|
||||
new FocasHostAddress("10.0.0.5", 12345).ToString().ShouldBe("focas://10.0.0.5:12345");
|
||||
}
|
||||
|
||||
// ---- FocasAddress ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0.0", FocasAreaKind.Pmc, "X", 0, 0)]
|
||||
[InlineData("X0", FocasAreaKind.Pmc, "X", 0, null)]
|
||||
[InlineData("Y10", FocasAreaKind.Pmc, "Y", 10, null)]
|
||||
[InlineData("F20.3", FocasAreaKind.Pmc, "F", 20, 3)]
|
||||
[InlineData("G54", FocasAreaKind.Pmc, "G", 54, null)]
|
||||
[InlineData("R100", FocasAreaKind.Pmc, "R", 100, null)]
|
||||
[InlineData("D200", FocasAreaKind.Pmc, "D", 200, null)]
|
||||
[InlineData("C300", FocasAreaKind.Pmc, "C", 300, null)]
|
||||
[InlineData("K400", FocasAreaKind.Pmc, "K", 400, null)]
|
||||
[InlineData("A500", FocasAreaKind.Pmc, "A", 500, null)]
|
||||
[InlineData("E600", FocasAreaKind.Pmc, "E", 600, null)]
|
||||
[InlineData("T50.4", FocasAreaKind.Pmc, "T", 50, 4)]
|
||||
public void Address_parses_PMC_forms(string input, FocasAreaKind kind, string letter, int num, int? bit)
|
||||
{
|
||||
var a = FocasAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.Kind.ShouldBe(kind);
|
||||
a.PmcLetter.ShouldBe(letter);
|
||||
a.Number.ShouldBe(num);
|
||||
a.BitIndex.ShouldBe(bit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PARAM:1020", FocasAreaKind.Parameter, 1020, null)]
|
||||
[InlineData("PARAM:1815/0", FocasAreaKind.Parameter, 1815, 0)]
|
||||
[InlineData("PARAM:1815/31", FocasAreaKind.Parameter, 1815, 31)]
|
||||
public void Address_parses_parameter_forms(string input, FocasAreaKind kind, int num, int? bit)
|
||||
{
|
||||
var a = FocasAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.Kind.ShouldBe(kind);
|
||||
a.PmcLetter.ShouldBeNull();
|
||||
a.Number.ShouldBe(num);
|
||||
a.BitIndex.ShouldBe(bit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MACRO:100", FocasAreaKind.Macro, 100)]
|
||||
[InlineData("MACRO:500", FocasAreaKind.Macro, 500)]
|
||||
public void Address_parses_macro_forms(string input, FocasAreaKind kind, int num)
|
||||
{
|
||||
var a = FocasAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.Kind.ShouldBe(kind);
|
||||
a.Number.ShouldBe(num);
|
||||
a.BitIndex.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Z0")] // unknown PMC letter
|
||||
[InlineData("X")] // missing number
|
||||
[InlineData("X-1")] // negative number
|
||||
[InlineData("Xabc")] // non-numeric
|
||||
[InlineData("X0.8")] // bit out of range (0-7)
|
||||
[InlineData("X0.-1")] // negative bit
|
||||
[InlineData("PARAM:")] // missing number
|
||||
[InlineData("PARAM:1815/32")] // bit out of range (0-31)
|
||||
[InlineData("MACRO:abc")] // non-numeric
|
||||
public void Address_rejects_invalid_forms(string? input)
|
||||
{
|
||||
FocasAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0.0")]
|
||||
[InlineData("R100")]
|
||||
[InlineData("F20.3")]
|
||||
[InlineData("PARAM:1020")]
|
||||
[InlineData("PARAM:1815/0")]
|
||||
[InlineData("MACRO:100")]
|
||||
public void Address_Canonical_roundtrips(string input)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Canonical.ShouldBe(input);
|
||||
}
|
||||
|
||||
// ---- FocasDataType ----
|
||||
|
||||
[Fact]
|
||||
public void DataType_mapping_covers_atomic_focas_types()
|
||||
{
|
||||
FocasDataType.Bit.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
FocasDataType.Int16.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
FocasDataType.Int32.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
FocasDataType.Float32.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
FocasDataType.Float64.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||
FocasDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
// ---- FocasStatusMapper ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, FocasStatusMapper.Good)]
|
||||
[InlineData(3, FocasStatusMapper.BadOutOfRange)] // EW_NUMBER
|
||||
[InlineData(4, FocasStatusMapper.BadOutOfRange)] // EW_LENGTH
|
||||
[InlineData(5, FocasStatusMapper.BadNotWritable)] // EW_PROT
|
||||
[InlineData(6, FocasStatusMapper.BadNotSupported)] // EW_NOOPT
|
||||
[InlineData(8, FocasStatusMapper.BadNodeIdUnknown)] // EW_DATA
|
||||
[InlineData(-1, FocasStatusMapper.BadDeviceFailure)] // EW_BUSY
|
||||
[InlineData(-8, FocasStatusMapper.BadInternalError)] // EW_HANDLE
|
||||
[InlineData(-16, FocasStatusMapper.BadCommunicationError)] // EW_SOCKET
|
||||
[InlineData(999, FocasStatusMapper.BadCommunicationError)] // unknown → generic
|
||||
public void StatusMapper_covers_known_focas_returns(int ret, uint expected)
|
||||
{
|
||||
FocasStatusMapper.MapFocasReturn(ret).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- FocasDriver ----
|
||||
|
||||
[Fact]
|
||||
public void DriverType_is_FOCAS()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions(), "drv-1");
|
||||
drv.DriverType.ShouldBe("FOCAS");
|
||||
drv.DriverInstanceId.ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_parses_device_addresses()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions("focas://10.0.0.5:8193"),
|
||||
new FocasDeviceOptions("focas://10.0.0.6:12345", DeviceName: "CNC-2"),
|
||||
],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(2);
|
||||
drv.GetDeviceState("focas://10.0.0.5:8193")!.ParsedAddress.Port.ShouldBe(8193);
|
||||
drv.GetDeviceState("focas://10.0.0.6:12345")!.Options.DeviceName.ShouldBe("CNC-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_malformed_address_faults()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("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 FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
// ---- UnimplementedFocasClientFactory ----
|
||||
|
||||
[Fact]
|
||||
public void Default_factory_throws_on_Create_with_deployment_pointer()
|
||||
{
|
||||
var factory = new UnimplementedFocasClientFactory();
|
||||
var ex = Should.Throw<NotSupportedException>(() => factory.Create());
|
||||
ex.Message.ShouldContain("Fwlib32.dll");
|
||||
ex.Message.ShouldContain("licensed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the managed helpers inside FwlibNative + FwlibFocasClient that don't require the
|
||||
/// licensed Fwlib32.dll — letter→ADR_* mapping, FocasDataType→data-type mapping, byte encoding.
|
||||
/// The actual P/Invoke calls can only run where the DLL is present; field testing covers those.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FwlibNativeHelperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("G", 0)]
|
||||
[InlineData("F", 1)]
|
||||
[InlineData("Y", 2)]
|
||||
[InlineData("X", 3)]
|
||||
[InlineData("A", 4)]
|
||||
[InlineData("R", 5)]
|
||||
[InlineData("T", 6)]
|
||||
[InlineData("K", 7)]
|
||||
[InlineData("C", 8)]
|
||||
[InlineData("D", 9)]
|
||||
[InlineData("E", 10)]
|
||||
[InlineData("g", 0)] // case-insensitive
|
||||
public void PmcAddrType_maps_every_valid_letter(string letter, short expected)
|
||||
{
|
||||
FocasPmcAddrType.FromLetter(letter).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Z")]
|
||||
[InlineData("")]
|
||||
[InlineData("XX")]
|
||||
public void PmcAddrType_rejects_unknown_letters(string letter)
|
||||
{
|
||||
FocasPmcAddrType.FromLetter(letter).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasDataType.Bit, 0)] // byte
|
||||
[InlineData(FocasDataType.Byte, 0)]
|
||||
[InlineData(FocasDataType.Int16, 1)] // word
|
||||
[InlineData(FocasDataType.Int32, 2)] // long
|
||||
[InlineData(FocasDataType.Float32, 4)]
|
||||
[InlineData(FocasDataType.Float64, 5)]
|
||||
public void PmcDataType_maps_FocasDataType_to_FOCAS_code(FocasDataType input, short expected)
|
||||
{
|
||||
FocasPmcDataType.FromFocasDataType(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Byte_writes_signed_byte_at_offset_0()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Byte, (sbyte)-5, bitIndex: null);
|
||||
((sbyte)buf[0]).ShouldBe((sbyte)-5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Int16_writes_little_endian()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int16, (short)0x1234, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)0x34);
|
||||
buf[1].ShouldBe((byte)0x12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Int32_writes_little_endian()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int32, 0x12345678, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)0x78);
|
||||
buf[1].ShouldBe((byte)0x56);
|
||||
buf[2].ShouldBe((byte)0x34);
|
||||
buf[3].ShouldBe((byte)0x12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Bit_throws_NotSupported_for_RMW_gap()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
Should.Throw<NotSupportedException>(() =>
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, true, bitIndex: 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeParamValue_Int32_writes_little_endian()
|
||||
{
|
||||
var buf = new byte[32];
|
||||
FwlibFocasClient.EncodeParamValue(buf, FocasDataType.Int32, 0x0A0B0C0D);
|
||||
buf[0].ShouldBe((byte)0x0D);
|
||||
buf[1].ShouldBe((byte)0x0C);
|
||||
buf[2].ShouldBe((byte)0x0B);
|
||||
buf[3].ShouldBe((byte)0x0A);
|
||||
}
|
||||
}
|
||||
@@ -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.FOCAS.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.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.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>
|
||||
@@ -0,0 +1,114 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// ---- notification fake ----
|
||||
|
||||
public List<FakeNotification> Notifications { get; } = new();
|
||||
public bool ThrowOnAddNotification { get; set; }
|
||||
|
||||
public virtual Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
||||
Action<string, object?> onChange, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ThrowOnAddNotification)
|
||||
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
|
||||
|
||||
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this);
|
||||
Notifications.Add(reg);
|
||||
return Task.FromResult<ITwinCATNotificationHandle>(reg);
|
||||
}
|
||||
|
||||
/// <summary>Fire a change event through the registered callback for <paramref name="symbolPath"/>.</summary>
|
||||
public void FireNotification(string symbolPath, object? value)
|
||||
{
|
||||
foreach (var n in Notifications)
|
||||
if (!n.Disposed && string.Equals(n.SymbolPath, symbolPath, StringComparison.OrdinalIgnoreCase))
|
||||
n.OnChange(symbolPath, value);
|
||||
}
|
||||
|
||||
public sealed class FakeNotification(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex,
|
||||
Action<string, object?> onChange, FakeTwinCATClient owner) : ITwinCATNotificationHandle
|
||||
{
|
||||
public string SymbolPath { get; } = symbolPath;
|
||||
public TwinCATDataType Type { get; } = type;
|
||||
public int? BitIndex { get; } = bitIndex;
|
||||
public Action<string, object?> OnChange { get; } = onChange;
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
owner.Notifications.Remove(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,257 @@
|
||||
using System.Collections.Concurrent;
|
||||
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 TwinCATCapabilityTests
|
||||
{
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_pre_declared_tags()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851", DeviceName: "Mach1")],
|
||||
Tags =
|
||||
[
|
||||
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition("Status", "ads://5.23.91.23.1.1:851", "GVL.Status", TwinCATDataType.Bool, Writable: false),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "TwinCAT");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "ads://5.23.91.23.1.1:851" && f.DisplayName == "Mach1");
|
||||
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Status").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_initial_poll_raises_OnDataChange()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 42 } },
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = false, // poll-mode test
|
||||
}, "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 ShutdownAsync_cancels_active_subscriptions()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } },
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = false, // poll-mode test
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
var afterShutdown = events.Count;
|
||||
await Task.Delay(200);
|
||||
events.Count.ShouldBe(afterShutdown);
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_returns_entry_per_device()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"),
|
||||
new TwinCATDeviceOptions("ads://5.23.91.24.1.1:851"),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetHostStatuses().Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Running_on_successful_probe()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { ProbeResult = true },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "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_probe_failure()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { ProbeResult = false },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "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_Enabled_is_false()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
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 TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"),
|
||||
new TwinCATDeviceOptions("ads://5.23.91.24.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.24.1.1:851", "MAIN.B", TwinCATDataType.DInt),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("A").ShouldBe("ads://5.23.91.23.1.1:851");
|
||||
drv.ResolveHost("B").ShouldBe("ads://5.23.91.24.1.1:851");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_first_device_for_unknown_ref()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("missing").ShouldBe("ads://5.23.91.23.1.1:851");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "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,107 @@
|
||||
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")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "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")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "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,221 @@
|
||||
using System.Collections.Concurrent;
|
||||
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 TwinCATNativeNotificationTests
|
||||
{
|
||||
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewNativeDriver(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 },
|
||||
UseNativeNotifications = true,
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_subscribe_registers_one_notification_per_tag()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
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));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
handle.DiagnosticId.ShouldStartWith("twincat-native-sub-");
|
||||
|
||||
factory.Clients[0].Notifications.Count.ShouldBe(2);
|
||||
factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.B"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_notification_fires_OnDataChange_with_pushed_value()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
|
||||
factory.Clients[0].FireNotification("MAIN.Speed", 4200);
|
||||
factory.Clients[0].FireNotification("MAIN.Speed", 4201);
|
||||
|
||||
events.Count.ShouldBe(2);
|
||||
events.Last().Snapshot.Value.ShouldBe(4201);
|
||||
events.Last().FullReference.ShouldBe("Speed"); // driver-side reference, not ADS symbol
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_unsubscribe_disposes_all_notifications()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
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));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
factory.Clients[0].Notifications.Count.ShouldBe(2);
|
||||
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
factory.Clients[0].Notifications.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_unsubscribe_halts_future_notifications()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||
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);
|
||||
factory.Clients[0].FireNotification("MAIN.X", 1);
|
||||
var snapshotFake = factory.Clients[0];
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
var afterUnsub = events.Count;
|
||||
// After unsubscribe the fake's Notifications list is empty so FireNotification finds nothing
|
||||
// to invoke. This mirrors the production contract — disposed handles no longer deliver.
|
||||
snapshotFake.FireNotification("MAIN.X", 999);
|
||||
events.Count.ShouldBe(afterUnsub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_subscribe_failure_mid_registration_cleans_up_partial_state()
|
||||
{
|
||||
// Fail-on-second-call fake — first AddNotificationAsync succeeds, second throws.
|
||||
// Subscribe's catch block must tear the first one down before rethrowing so no zombie
|
||||
// notification lingers.
|
||||
var fake = new FailAfterNAddsFake(new AbTagParamsIrrelevant(), succeedBefore: 1);
|
||||
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
|
||||
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),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None));
|
||||
|
||||
// First registration succeeded then got torn down by the catch; second threw.
|
||||
fake.AddCallCount.ShouldBe(2);
|
||||
fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up
|
||||
}
|
||||
|
||||
private sealed class AbTagParamsIrrelevant { }
|
||||
|
||||
private sealed class FailAfterNAddsFake : FakeTwinCATClient
|
||||
{
|
||||
private readonly int _succeedBefore;
|
||||
public int AddCallCount { get; private set; }
|
||||
|
||||
public FailAfterNAddsFake(AbTagParamsIrrelevant _, int succeedBefore) : base()
|
||||
{
|
||||
_succeedBefore = succeedBefore;
|
||||
}
|
||||
|
||||
public override Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
||||
Action<string, object?> onChange, CancellationToken cancellationToken)
|
||||
{
|
||||
AddCallCount++;
|
||||
if (AddCallCount > _succeedBefore)
|
||||
throw new InvalidOperationException($"fake fail on call #{AddCallCount}");
|
||||
return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, onChange, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_shutdown_disposes_subscriptions()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
factory.Clients[0].Notifications.Count.ShouldBe(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
factory.Clients[0].Notifications.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poll_path_still_works_when_UseNativeNotifications_false()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 7 } },
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = 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(150), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.First().Snapshot.Value.ShouldBe(7);
|
||||
factory.Clients[0].Notifications.ShouldBeEmpty(); // no native notifications on poll path
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_handle_DiagnosticId_indicates_native_vs_poll()
|
||||
{
|
||||
var (drvNative, _) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||
await drvNative.InitializeAsync("{}", CancellationToken.None);
|
||||
var nativeHandle = await drvNative.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
nativeHandle.DiagnosticId.ShouldContain("native");
|
||||
|
||||
var factoryPoll = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } },
|
||||
};
|
||||
var drvPoll = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = false,
|
||||
}, "drv-1", factoryPoll);
|
||||
await drvPoll.InitializeAsync("{}", CancellationToken.None);
|
||||
var pollHandle = await drvPoll.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
pollHandle.DiagnosticId.ShouldNotContain("native");
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
@@ -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