fix(driver-twincat): resolve High code-review findings (Driver.TwinCAT-001, -002, -007, -008, -013)
Driver.TwinCAT-001 — InitializeAsync/ReinitializeAsync ignored driverConfigJson. Extracted the DTO-to-options parse into a shared TwinCATDriverFactoryExtensions.ParseOptions; InitializeAsync now re-parses driverConfigJson into a mutable _options field, so a config generation pushed via ReinitializeAsync (added/removed devices, tags, probe settings) is actually applied at runtime. Driver.TwinCAT-002 — LInt/ULInt narrowed to Int32. ToDriverDataType now maps LInt to Int64, ULInt to UInt64, UDInt to UInt32, UInt/USInt to UInt16, Int/SInt to Int16, and the IEC TIME/DATE/DT/TOD types to UInt32 (their raw UDINT counter). Removed the stale "Int64 gap" comment — no truncation or sign flips at the OPC UA encode layer. Driver.TwinCAT-007 — EnsureConnectedAsync was not thread-safe. Connect/reconnect is now serialized per device by a SemaphoreSlim (DeviceState.ConnectGate) with a double-checked connect, mirroring the S7 driver. Concurrent read/write/probe callers can no longer leak a client or race a create-vs-dispose. Driver.TwinCAT-008 — native ADS notification callbacks ran driver logic on the AMS router thread. AdsTwinCATClient now enqueues AdsNotificationEx callbacks onto a bounded Channel drained by a dedicated managed task; the router-thread callback only does a non-blocking TryWrite, so a slow consumer cannot stall ADS notification delivery process-wide. Driver.TwinCAT-013 — TwinCATDriver did not implement IRediscoverable. The driver now implements IRediscoverable; AdsTwinCATClient detects ADS 0x0702 (symbol-version-changed) on read/write paths and raises OnSymbolVersionChanged, which the driver forwards as OnRediscoveryNeeded so Core rebuilds the address space after a PLC program re-download. Adds TwinCATHighFindingsRegressionTests covering all five fixes; updates the data-type mapping assertion in TwinCATDriverTests. TwinCAT driver builds clean; 119 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-05-22 |
|
| Review date | 2026-05-22 |
|
||||||
| Commit reviewed | `76d35d1` |
|
| Commit reviewed | `76d35d1` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 16 |
|
| Open findings | 11 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ a category produced nothing rather than leaving it blank.
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `TwinCATDriver.cs:41-78` |
|
| Location | `TwinCATDriver.cs:41-78` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `InitializeAsync` and `ReinitializeAsync` both ignore their `driverConfigJson`
|
**Description:** `InitializeAsync` and `ReinitializeAsync` both ignore their `driverConfigJson`
|
||||||
parameter entirely. `InitializeAsync` builds device/tag state exclusively from `_options`,
|
parameter entirely. `InitializeAsync` builds device/tag state exclusively from `_options`,
|
||||||
@@ -55,7 +55,7 @@ parser) and assign the resulting options to a mutable field, rather than relying
|
|||||||
constructor-captured `_options`. Alternatively, document explicitly that the constructor is
|
constructor-captured `_options`. Alternatively, document explicitly that the constructor is
|
||||||
the sole config source and have the Core recreate the driver instance on config change.
|
the sole config source and have the Core recreate the driver instance on config change.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — extracted the DTO→options parse into a shared TwinCATDriverFactoryExtensions.ParseOptions; InitializeAsync re-parses driverConfigJson into a now-mutable _options field, so ReinitializeAsync applies a changed config generation.
|
||||||
|
|
||||||
### Driver.TwinCAT-002
|
### Driver.TwinCAT-002
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ the sole config source and have the Core recreate the driver instance on config
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `TwinCATDataType.cs:34-48`, `AdsTwinCATClient.cs:264-281` |
|
| Location | `TwinCATDataType.cs:34-48`, `AdsTwinCATClient.cs:264-281` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `TwinCATDataTypeExtensions.ToDriverDataType` maps `LInt` and `ULInt` (signed/
|
**Description:** `TwinCATDataTypeExtensions.ToDriverDataType` maps `LInt` and `ULInt` (signed/
|
||||||
unsigned 64-bit) to `DriverDataType.Int32` (comment: "matches Int64 gap"). The address-space
|
unsigned 64-bit) to `DriverDataType.Int32` (comment: "matches Int64 gap"). The address-space
|
||||||
@@ -79,7 +79,7 @@ the range 0x80000000 to 0xFFFFFFFF surface as negative.
|
|||||||
**Recommendation:** Map `LInt` to `Int64`, `ULInt` to `UInt64`, `UDInt` to `UInt32`, `UInt`
|
**Recommendation:** Map `LInt` to `Int64`, `ULInt` to `UInt64`, `UDInt` to `UInt32`, `UInt`
|
||||||
to `UInt16`, and `USInt`/`SInt` to their natural widths. Remove the stale "Int64 gap" comment.
|
to `UInt16`, and `USInt`/`SInt` to their natural widths. Remove the stale "Int64 gap" comment.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — ToDriverDataType now maps LInt→Int64, ULInt→UInt64, UDInt→UInt32, UInt/USInt→UInt16, Int/SInt→Int16, and the IEC time types→UInt32; removed the stale Int64-gap comment.
|
||||||
|
|
||||||
### Driver.TwinCAT-003
|
### Driver.TwinCAT-003
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ fallback. Prefer the first device HostAddress only when one exists (already done
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `TwinCATDriver.cs:413-429` |
|
| Location | `TwinCATDriver.cs:413-429` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `EnsureConnectedAsync` is not thread-safe. `ReadAsync`, `WriteAsync`,
|
**Description:** `EnsureConnectedAsync` is not thread-safe. `ReadAsync`, `WriteAsync`,
|
||||||
`SubscribeAsync`, and the per-device `ProbeLoopAsync` background task can all call it
|
`SubscribeAsync`, and the per-device `ProbeLoopAsync` background task can all call it
|
||||||
@@ -196,7 +196,7 @@ continuously, so this race is not hypothetical under any concurrent read/write l
|
|||||||
serialize device access with a `SemaphoreSlim` — follow that pattern. Note this also
|
serialize device access with a `SemaphoreSlim` — follow that pattern. Note this also
|
||||||
serializes the wire, which `docs/v2/driver-specs.md` recommends for single-connection-per-PLC.
|
serializes the wire, which `docs/v2/driver-specs.md` recommends for single-connection-per-PLC.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — EnsureConnectedAsync is now serialized per device by a SemaphoreSlim (DeviceState.ConnectGate) with a double-checked connect, mirroring the S7 driver; no client is leaked and no disposal race remains.
|
||||||
|
|
||||||
### Driver.TwinCAT-008
|
### Driver.TwinCAT-008
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ serializes the wire, which `docs/v2/driver-specs.md` recommends for single-conne
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `AdsTwinCATClient.cs:162-169`, `TwinCATDriver.cs:319-324` |
|
| Location | `AdsTwinCATClient.cs:162-169`, `TwinCATDriver.cs:319-324` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** Native ADS notification callbacks (`OnAdsNotificationEx`) run on the
|
**Description:** Native ADS notification callbacks (`OnAdsNotificationEx`) run on the
|
||||||
`AdsClient` AMS router thread. `docs/v2/driver-specs.md` section 6 explicitly calls this out
|
`AdsClient` AMS router thread. `docs/v2/driver-specs.md` section 6 explicitly calls this out
|
||||||
@@ -221,7 +221,7 @@ device in the process.
|
|||||||
a dedicated managed task before invoking `OnChange`/`OnDataChange`, exactly as the Galaxy
|
a dedicated managed task before invoking `OnChange`/`OnDataChange`, exactly as the Galaxy
|
||||||
`EventPump` does. Keep the router-thread callback to a non-blocking enqueue only.
|
`EventPump` does. Keep the router-thread callback to a non-blocking enqueue only.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — AdsTwinCATClient now enqueues native AdsNotificationEx callbacks onto a bounded Channel drained by a dedicated managed task; the AMS router thread only does a non-blocking TryWrite, so a slow consumer cannot stall ADS delivery.
|
||||||
|
|
||||||
### Driver.TwinCAT-009
|
### Driver.TwinCAT-009
|
||||||
|
|
||||||
@@ -334,7 +334,7 @@ stream-and-discard design is intentional, report the real footprint of `_nativeS
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Location | `TwinCATDriver.cs:11-12` (capability list), whole file |
|
| Location | `TwinCATDriver.cs:11-12` (capability list), whole file |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `TwinCATDriver` does not implement `IRediscoverable`. Both
|
**Description:** `TwinCATDriver` does not implement `IRediscoverable`. Both
|
||||||
`docs/v2/driver-specs.md` section 6 and `docs/v2/driver-stability.md` section "TwinCAT — Deep
|
`docs/v2/driver-specs.md` section 6 and `docs/v2/driver-stability.md` section "TwinCAT — Deep
|
||||||
@@ -352,7 +352,7 @@ on read/write/notification paths, raise `OnRediscoveryNeeded` with a scoped reas
|
|||||||
re-establish native notifications after the Core re-runs `DiscoverAsync`. This is explicitly
|
re-establish native notifications after the Core re-runs `DiscoverAsync`. This is explicitly
|
||||||
part of the documented driver contract, not optional.
|
part of the documented driver contract, not optional.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — TwinCATDriver implements IRediscoverable; AdsTwinCATClient detects ADS 0x0702 on read/write paths and raises OnSymbolVersionChanged, which the driver forwards as OnRediscoveryNeeded so Core rebuilds the address space.
|
||||||
|
|
||||||
### Driver.TwinCAT-014
|
### Driver.TwinCAT-014
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading.Channels;
|
||||||
using TwinCAT;
|
using TwinCAT;
|
||||||
using TwinCAT.Ads;
|
using TwinCAT.Ads;
|
||||||
using TwinCAT.Ads.TypeSystem;
|
using TwinCAT.Ads.TypeSystem;
|
||||||
@@ -21,16 +22,50 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class AdsTwinCATClient : ITwinCATClient
|
internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||||
{
|
{
|
||||||
|
// Bounded so a slow downstream consumer cannot back the AMS router thread up — the
|
||||||
|
// router thread enqueues and returns immediately (Driver.TwinCAT-008). 50k matches the
|
||||||
|
// Galaxy EventPump default; ~500 notifications/connection is the ADS ceiling so this is
|
||||||
|
// generous headroom against bursty change storms.
|
||||||
|
private const int NotificationQueueCapacity = 50_000;
|
||||||
|
|
||||||
private readonly AdsClient _client = new();
|
private readonly AdsClient _client = new();
|
||||||
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
|
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
|
||||||
|
|
||||||
|
// Marshals native ADS notifications off the AMS router thread onto a dedicated managed
|
||||||
|
// task. The router-thread callback (OnAdsNotificationEx) only enqueues; DispatchLoopAsync
|
||||||
|
// drains and invokes the per-tag OnChange delegates. Blocking the router thread would
|
||||||
|
// stall every ADS notification across the whole process (docs/v2/driver-specs.md §6).
|
||||||
|
private readonly Channel<PendingNotification> _notificationQueue =
|
||||||
|
Channel.CreateBounded<PendingNotification>(new BoundedChannelOptions(NotificationQueueCapacity)
|
||||||
|
{
|
||||||
|
FullMode = BoundedChannelFullMode.DropWrite,
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = false,
|
||||||
|
});
|
||||||
|
private readonly CancellationTokenSource _dispatchCts = new();
|
||||||
|
private readonly Task _dispatchLoop;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
public AdsTwinCATClient()
|
public AdsTwinCATClient()
|
||||||
{
|
{
|
||||||
_client.AdsNotificationEx += OnAdsNotificationEx;
|
_client.AdsNotificationEx += OnAdsNotificationEx;
|
||||||
|
_dispatchLoop = Task.Run(() => DispatchLoopAsync(_dispatchCts.Token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly record struct PendingNotification(NotificationRegistration Registration, object? Value);
|
||||||
|
|
||||||
public bool IsConnected => _client.IsConnected;
|
public bool IsConnected => _client.IsConnected;
|
||||||
|
|
||||||
|
public event EventHandler? OnSymbolVersionChanged;
|
||||||
|
|
||||||
|
/// <summary>Raise <see cref="OnSymbolVersionChanged"/> when <paramref name="adsError"/> is 0x0702.</summary>
|
||||||
|
private uint MapAndSignal(uint adsError)
|
||||||
|
{
|
||||||
|
if (TwinCATStatusMapper.IsSymbolVersionChanged(adsError))
|
||||||
|
OnSymbolVersionChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
return TwinCATStatusMapper.MapAdsError(adsError);
|
||||||
|
}
|
||||||
|
|
||||||
public Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
public Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_client.IsConnected) return Task.CompletedTask;
|
if (_client.IsConnected) return Task.CompletedTask;
|
||||||
@@ -60,7 +95,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
|
var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
if (parentResult.ErrorCode != AdsErrorCode.NoError)
|
if (parentResult.ErrorCode != AdsErrorCode.NoError)
|
||||||
return (null, TwinCATStatusMapper.MapAdsError((uint)parentResult.ErrorCode));
|
return (null, MapAndSignal((uint)parentResult.ErrorCode));
|
||||||
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
|
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,13 +104,13 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||||
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
|
return (null, MapAndSignal((uint)result.ErrorCode));
|
||||||
|
|
||||||
return (result.Value, TwinCATStatusMapper.Good);
|
return (result.Value, TwinCATStatusMapper.Good);
|
||||||
}
|
}
|
||||||
catch (AdsErrorException ex)
|
catch (AdsErrorException ex)
|
||||||
{
|
{
|
||||||
return (null, TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode));
|
return (null, MapAndSignal((uint)ex.ErrorCode));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,11 +141,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
return result.ErrorCode == AdsErrorCode.NoError
|
return result.ErrorCode == AdsErrorCode.NoError
|
||||||
? TwinCATStatusMapper.Good
|
? TwinCATStatusMapper.Good
|
||||||
: TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode);
|
: MapAndSignal((uint)result.ErrorCode);
|
||||||
}
|
}
|
||||||
catch (AdsErrorException ex)
|
catch (AdsErrorException ex)
|
||||||
{
|
{
|
||||||
return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
return MapAndSignal((uint)ex.ErrorCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,13 +194,35 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
return reg;
|
return reg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap bit-extraction
|
||||||
|
/// decode then enqueues — no driver logic, no consumer callbacks — so a slow consumer
|
||||||
|
/// can never stall ADS notification delivery for the rest of the process
|
||||||
|
/// (Driver.TwinCAT-008). Drops the notification (DropWrite) if the queue is saturated.
|
||||||
|
/// </summary>
|
||||||
private void OnAdsNotificationEx(object? sender, AdsNotificationExEventArgs args)
|
private void OnAdsNotificationEx(object? sender, AdsNotificationExEventArgs args)
|
||||||
{
|
{
|
||||||
if (!_notifications.TryGetValue(args.Handle, out var reg)) return;
|
if (!_notifications.TryGetValue(args.Handle, out var reg)) return;
|
||||||
var value = args.Value;
|
var value = args.Value;
|
||||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
|
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
|
||||||
value = ExtractBit(value, bit);
|
value = ExtractBit(value, bit);
|
||||||
try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ }
|
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DispatchLoopAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (var pending in _notificationQueue.Reader.ReadAllAsync(ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
try { pending.Registration.OnChange(pending.Registration.SymbolPath, pending.Value); }
|
||||||
|
catch { /* consumer-side errors stay on this managed task, not the ADS thread */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Clean shutdown.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task DeleteNotificationAsync(uint handle, CancellationToken cancellationToken)
|
internal async Task DeleteNotificationAsync(uint handle, CancellationToken cancellationToken)
|
||||||
@@ -236,8 +293,17 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||||
_client.AdsNotificationEx -= OnAdsNotificationEx;
|
_client.AdsNotificationEx -= OnAdsNotificationEx;
|
||||||
_notifications.Clear();
|
_notifications.Clear();
|
||||||
|
|
||||||
|
// Stop the dispatch loop: complete the queue so the reader drains + exits, then
|
||||||
|
// cancel as a backstop. Best-effort wait so a wedged consumer can't hang teardown.
|
||||||
|
_notificationQueue.Writer.TryComplete();
|
||||||
|
_dispatchCts.Cancel();
|
||||||
|
try { _dispatchLoop.Wait(TimeSpan.FromSeconds(2)); } catch { /* shutdown */ }
|
||||||
|
_dispatchCts.Dispose();
|
||||||
|
|
||||||
_client.Dispose();
|
_client.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ public interface ITwinCATClient : IDisposable
|
|||||||
/// <summary>True when the AMS router + target both accept commands.</summary>
|
/// <summary>True when the AMS router + target both accept commands.</summary>
|
||||||
bool IsConnected { get; }
|
bool IsConnected { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the client observes the ADS symbol-version-changed code
|
||||||
|
/// (<see cref="TwinCATStatusMapper.AdsSymbolVersionChanged"/>) on any read / write /
|
||||||
|
/// notification — the signal that a PLC program re-download has invalidated every
|
||||||
|
/// symbol + notification handle. The driver forwards this to
|
||||||
|
/// <see cref="Core.Abstractions.IRediscoverable.OnRediscoveryNeeded"/> so Core rebuilds
|
||||||
|
/// the address space subtree (docs/v2/driver-specs.md §6, Driver.TwinCAT-013).
|
||||||
|
/// </summary>
|
||||||
|
event EventHandler? OnSymbolVersionChanged;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read a symbolic value. Returns a boxed .NET value matching the requested
|
/// 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
|
/// <paramref name="type"/>, or <c>null</c> when the read produced no data; the
|
||||||
|
|||||||
@@ -34,15 +34,19 @@ public static class TwinCATDataTypeExtensions
|
|||||||
public static DriverDataType ToDriverDataType(this TwinCATDataType t) => t switch
|
public static DriverDataType ToDriverDataType(this TwinCATDataType t) => t switch
|
||||||
{
|
{
|
||||||
TwinCATDataType.Bool => DriverDataType.Boolean,
|
TwinCATDataType.Bool => DriverDataType.Boolean,
|
||||||
TwinCATDataType.SInt or TwinCATDataType.USInt
|
TwinCATDataType.SInt => DriverDataType.Int16, // signed 8-bit — no narrower OPC UA atom
|
||||||
or TwinCATDataType.Int or TwinCATDataType.UInt
|
TwinCATDataType.USInt => DriverDataType.UInt16, // unsigned 8-bit — no narrower OPC UA atom
|
||||||
or TwinCATDataType.DInt or TwinCATDataType.UDInt => DriverDataType.Int32,
|
TwinCATDataType.Int => DriverDataType.Int16,
|
||||||
TwinCATDataType.LInt or TwinCATDataType.ULInt => DriverDataType.Int32, // matches Int64 gap
|
TwinCATDataType.UInt => DriverDataType.UInt16,
|
||||||
|
TwinCATDataType.DInt => DriverDataType.Int32,
|
||||||
|
TwinCATDataType.UDInt => DriverDataType.UInt32,
|
||||||
|
TwinCATDataType.LInt => DriverDataType.Int64,
|
||||||
|
TwinCATDataType.ULInt => DriverDataType.UInt64,
|
||||||
TwinCATDataType.Real => DriverDataType.Float32,
|
TwinCATDataType.Real => DriverDataType.Float32,
|
||||||
TwinCATDataType.LReal => DriverDataType.Float64,
|
TwinCATDataType.LReal => DriverDataType.Float64,
|
||||||
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
|
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
|
||||||
TwinCATDataType.Time or TwinCATDataType.Date
|
TwinCATDataType.Time or TwinCATDataType.Date
|
||||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.Int32,
|
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.UInt32,
|
||||||
TwinCATDataType.Structure => DriverDataType.String,
|
TwinCATDataType.Structure => DriverDataType.String,
|
||||||
_ => DriverDataType.Int32,
|
_ => DriverDataType.Int32,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
|||||||
/// resolver land in PRs 2 and 3.
|
/// resolver land in PRs 2 and 3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly TwinCATDriverOptions _options;
|
// Mutable so ReinitializeAsync can apply a new config generation (Driver.TwinCAT-001).
|
||||||
|
// The constructor seeds it; InitializeAsync re-parses driverConfigJson over the top of it.
|
||||||
|
private TwinCATDriverOptions _options;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
private readonly ITwinCATClientFactory _clientFactory;
|
private readonly ITwinCATClientFactory _clientFactory;
|
||||||
private readonly PollGroupEngine _poll;
|
private readonly PollGroupEngine _poll;
|
||||||
@@ -21,6 +23,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||||
|
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
|
||||||
|
|
||||||
public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId,
|
public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId,
|
||||||
ITwinCATClientFactory? clientFactory = null)
|
ITwinCATClientFactory? clientFactory = null)
|
||||||
@@ -43,6 +46,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Apply the supplied config generation (Driver.TwinCAT-001). A blank or content-free
|
||||||
|
// document keeps the constructor-seeded options — that path covers callers that have
|
||||||
|
// already materialised options up front (the factory passes both, in agreement).
|
||||||
|
if (!string.IsNullOrWhiteSpace(driverConfigJson))
|
||||||
|
{
|
||||||
|
var parsed = TwinCATDriverFactoryExtensions.ParseOptions(driverConfigJson, _driverInstanceId);
|
||||||
|
if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0)
|
||||||
|
_options = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var device in _options.Devices)
|
foreach (var device in _options.Devices)
|
||||||
{
|
{
|
||||||
var addr = TwinCATAmsAddress.TryParse(device.HostAddress)
|
var addr = TwinCATAmsAddress.TryParse(device.HostAddress)
|
||||||
@@ -92,6 +105,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
state.ProbeCts?.Dispose();
|
state.ProbeCts?.Dispose();
|
||||||
state.ProbeCts = null;
|
state.ProbeCts = null;
|
||||||
state.DisposeClient();
|
state.DisposeClient();
|
||||||
|
state.DisposeGate();
|
||||||
}
|
}
|
||||||
_devices.Clear();
|
_devices.Clear();
|
||||||
_tagsByName.Clear();
|
_tagsByName.Clear();
|
||||||
@@ -410,24 +424,64 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lazily connect a device's client, serialized per device by
|
||||||
|
/// <see cref="DeviceState.ConnectGate"/> (Driver.TwinCAT-007). Without the gate, a
|
||||||
|
/// concurrent read / write / probe could each create + connect a separate client and
|
||||||
|
/// leak all-but-one, or dispose a client another thread is mid-connect on. The S7 and
|
||||||
|
/// AB-CIP drivers serialize device access the same way; single-connection-per-PLC is
|
||||||
|
/// also what docs/v2/driver-specs.md recommends.
|
||||||
|
/// </summary>
|
||||||
private async Task<ITwinCATClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
|
private async Task<ITwinCATClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (device.Client is { IsConnected: true } c) return c;
|
// Fast path — already connected, no gate needed.
|
||||||
device.Client ??= _clientFactory.Create();
|
if (device.Client is { IsConnected: true } fast) return fast;
|
||||||
|
|
||||||
|
await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct)
|
// Re-check under the gate: another caller may have connected while we waited.
|
||||||
.ConfigureAwait(false);
|
if (device.Client is { IsConnected: true } c) return c;
|
||||||
|
|
||||||
|
// Discard a stale (created-but-disconnected) client before making a fresh one.
|
||||||
|
if (device.Client is { IsConnected: false } stale)
|
||||||
|
{
|
||||||
|
try { stale.Dispose(); } catch { /* best-effort */ }
|
||||||
|
device.Client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = _clientFactory.Create();
|
||||||
|
client.OnSymbolVersionChanged += HandleSymbolVersionChanged;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
client.OnSymbolVersionChanged -= HandleSymbolVersionChanged;
|
||||||
|
client.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
device.Client = client;
|
||||||
|
return client;
|
||||||
}
|
}
|
||||||
catch
|
finally
|
||||||
{
|
{
|
||||||
device.Client.Dispose();
|
device.ConnectGate.Release();
|
||||||
device.Client = null;
|
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
return device.Client;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes a wire-detected ADS symbol-version-changed (0x0702) to Core as an
|
||||||
|
/// <see cref="IRediscoverable"/> invocation (Driver.TwinCAT-013). A PLC re-download
|
||||||
|
/// invalidates every symbol + notification handle, so the address space must be rebuilt
|
||||||
|
/// — this is the documented TwinCAT failure mode, not a transient connection error.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleSymbolVersionChanged(object? sender, EventArgs e) =>
|
||||||
|
OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(
|
||||||
|
"TwinCAT symbol-version-changed 0x0702 — PLC program re-downloaded", ScopeHint: "TwinCAT"));
|
||||||
|
|
||||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -437,6 +491,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
public TwinCATDeviceOptions Options { get; } = options;
|
public TwinCATDeviceOptions Options { get; } = options;
|
||||||
public ITwinCATClient? Client { get; set; }
|
public ITwinCATClient? Client { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Serializes connect / reconnect so concurrent callers never race a client
|
||||||
|
/// create-or-dispose for this device (Driver.TwinCAT-007).</summary>
|
||||||
|
public SemaphoreSlim ConnectGate { get; } = new(1, 1);
|
||||||
|
|
||||||
public object ProbeLock { get; } = new();
|
public object ProbeLock { get; } = new();
|
||||||
public HostState HostState { get; set; } = HostState.Unknown;
|
public HostState HostState { get; set; } = HostState.Unknown;
|
||||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||||
@@ -447,5 +505,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
Client?.Dispose();
|
Client?.Dispose();
|
||||||
Client = null;
|
Client = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void DisposeGate() => ConnectGate.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,25 @@ public static class TwinCATDriverFactoryExtensions
|
|||||||
internal static TwinCATDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
internal static TwinCATDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
var options = ParseOptions(driverConfigJson, driverInstanceId);
|
||||||
|
return new TwinCATDriver(options, driverInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a TwinCAT driver-config JSON document into a <see cref="TwinCATDriverOptions"/>.
|
||||||
|
/// Shared by <see cref="CreateInstance"/> (constructor-time) and
|
||||||
|
/// <see cref="TwinCATDriver.InitializeAsync"/> / <see cref="TwinCATDriver.ReinitializeAsync"/>
|
||||||
|
/// so a config generation pushed via Reinitialize is actually applied (Driver.TwinCAT-001).
|
||||||
|
/// </summary>
|
||||||
|
internal static TwinCATDriverOptions ParseOptions(string driverConfigJson, string driverInstanceId)
|
||||||
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
var dto = JsonSerializer.Deserialize<TwinCATDriverConfigDto>(driverConfigJson, JsonOptions)
|
var dto = JsonSerializer.Deserialize<TwinCATDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"TwinCAT driver config for '{driverInstanceId}' deserialised to null");
|
$"TwinCAT driver config for '{driverInstanceId}' deserialised to null");
|
||||||
|
|
||||||
var options = new TwinCATDriverOptions
|
return new TwinCATDriverOptions
|
||||||
{
|
{
|
||||||
Devices = dto.Devices is { Count: > 0 }
|
Devices = dto.Devices is { Count: > 0 }
|
||||||
? [.. dto.Devices.Select(d => new TwinCATDeviceOptions(
|
? [.. dto.Devices.Select(d => new TwinCATDeviceOptions(
|
||||||
@@ -49,8 +61,6 @@ public static class TwinCATDriverFactoryExtensions
|
|||||||
UseNativeNotifications = dto.UseNativeNotifications ?? true,
|
UseNativeNotifications = dto.UseNativeNotifications ?? true,
|
||||||
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new TwinCATDriver(options, driverInstanceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TwinCATTagDefinition BuildTag(TwinCATTagDto t, string driverInstanceId) =>
|
private static TwinCATTagDefinition BuildTag(TwinCATTagDto t, string driverInstanceId) =>
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
|||||||
public static class TwinCATStatusMapper
|
public static class TwinCATStatusMapper
|
||||||
{
|
{
|
||||||
public const uint Good = 0u;
|
public const uint Good = 0u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADS <c>ADSERR_DEVICE_SYMBOLVERSIONINVALID</c> — error <c>0x0702</c> (1794 decimal).
|
||||||
|
/// Raised by the runtime after a PLC program re-download: every symbol handle and
|
||||||
|
/// notification handle the driver holds is now stale. The driver treats this as an
|
||||||
|
/// <see cref="Core.Abstractions.IRediscoverable"/> trigger, not a connection error
|
||||||
|
/// (docs/v2/driver-specs.md §6, Driver.TwinCAT-013).
|
||||||
|
/// </summary>
|
||||||
|
public const uint AdsSymbolVersionChanged = 0x0702u;
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="adsError"/> is the symbol-version-changed code.</summary>
|
||||||
|
public static bool IsSymbolVersionChanged(uint adsError) => adsError == AdsSymbolVersionChanged;
|
||||||
|
|
||||||
public const uint BadInternalError = 0x80020000u;
|
public const uint BadInternalError = 0x80020000u;
|
||||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||||
public const uint BadNotWritable = 0x803B0000u;
|
public const uint BadNotWritable = 0x803B0000u;
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ internal class FakeTwinCATClient : ITwinCATClient
|
|||||||
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();
|
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();
|
||||||
public bool ProbeResult { get; set; } = true;
|
public bool ProbeResult { get; set; } = true;
|
||||||
|
|
||||||
|
public event EventHandler? OnSymbolVersionChanged;
|
||||||
|
|
||||||
|
/// <summary>Test hook — fire the symbol-version-changed signal as the real client would.</summary>
|
||||||
|
public void FireSymbolVersionChanged() => OnSymbolVersionChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
public virtual Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct)
|
public virtual Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ConnectCount++;
|
ConnectCount++;
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ public sealed class TwinCATDriverTests
|
|||||||
TwinCATDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
TwinCATDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||||
TwinCATDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
TwinCATDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||||
TwinCATDataType.WString.ToDriverDataType().ShouldBe(DriverDataType.String);
|
TwinCATDataType.WString.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||||
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
// IEC TIME/DATE/DT/TOD surface as their raw UDINT counter (Driver.TwinCAT-002).
|
||||||
|
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for the five High code-review findings closed 2026-05-22:
|
||||||
|
/// Driver.TwinCAT-001 (driverConfigJson ignored), -002 (LInt/ULInt narrowed to Int32),
|
||||||
|
/// -007 (EnsureConnectedAsync not thread-safe), -008 (ADS callbacks on the router thread),
|
||||||
|
/// -013 (no IRediscoverable / symbol-version-changed handling).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class TwinCATHighFindingsRegressionTests
|
||||||
|
{
|
||||||
|
private const string DeviceA = "ads://5.23.91.23.1.1:851";
|
||||||
|
|
||||||
|
// ---- Driver.TwinCAT-001 — Reinitialize applies the new config generation ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReinitializeAsync_applies_changed_device_config()
|
||||||
|
{
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions(DeviceA)],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
drv.DeviceCount.ShouldBe(1);
|
||||||
|
|
||||||
|
// A config generation that adds a second device must be picked up at runtime.
|
||||||
|
var newConfig = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
probe = new { enabled = false },
|
||||||
|
devices = new[]
|
||||||
|
{
|
||||||
|
new { hostAddress = DeviceA, deviceName = "Machine1" },
|
||||||
|
new { hostAddress = "ads://10.0.0.1.1.1:852", deviceName = "Machine2" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await drv.ReinitializeAsync(newConfig, CancellationToken.None);
|
||||||
|
|
||||||
|
drv.DeviceCount.ShouldBe(2);
|
||||||
|
drv.GetDeviceState("ads://10.0.0.1.1.1:852")!.Options.DeviceName.ShouldBe("Machine2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InitializeAsync_applies_supplied_config_over_constructor_options()
|
||||||
|
{
|
||||||
|
// Constructor seeds an empty option set; the JSON document is the authoritative config.
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
|
||||||
|
var config = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
probe = new { enabled = false },
|
||||||
|
devices = new[] { new { hostAddress = DeviceA } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await drv.InitializeAsync(config, CancellationToken.None);
|
||||||
|
|
||||||
|
drv.DeviceCount.ShouldBe(1);
|
||||||
|
drv.GetDeviceState(DeviceA).ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.TwinCAT-002 — 64-bit + unsigned types map without truncation ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DataType_mapping_preserves_width_and_signedness()
|
||||||
|
{
|
||||||
|
TwinCATDataType.LInt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
|
||||||
|
TwinCATDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64);
|
||||||
|
TwinCATDataType.UDInt.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
|
||||||
|
TwinCATDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||||
|
TwinCATDataType.UInt.ToDriverDataType().ShouldBe(DriverDataType.UInt16);
|
||||||
|
TwinCATDataType.Int.ToDriverDataType().ShouldBe(DriverDataType.Int16);
|
||||||
|
TwinCATDataType.USInt.ToDriverDataType().ShouldBe(DriverDataType.UInt16);
|
||||||
|
TwinCATDataType.SInt.ToDriverDataType().ShouldBe(DriverDataType.Int16);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LInt_read_round_trips_value_above_int_MaxValue()
|
||||||
|
{
|
||||||
|
var factory = new FakeTwinCATClientFactory();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions(DeviceA)],
|
||||||
|
Tags = [new TwinCATTagDefinition("Big", DeviceA, "GVL.Big", TwinCATDataType.LInt)],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
long big = (long)int.MaxValue + 1_000;
|
||||||
|
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.Big"] = big } };
|
||||||
|
|
||||||
|
var snapshot = (await drv.ReadAsync(["Big"], CancellationToken.None)).Single();
|
||||||
|
|
||||||
|
snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
||||||
|
snapshot.Value.ShouldBe(big); // no truncation into a 32-bit node
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.TwinCAT-007 — concurrent EnsureConnectedAsync creates exactly one client ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Concurrent_reads_on_one_device_create_a_single_client()
|
||||||
|
{
|
||||||
|
var factory = new FakeTwinCATClientFactory();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions(DeviceA)],
|
||||||
|
Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 7 } };
|
||||||
|
|
||||||
|
// 32 readers race the lazy connect. Without the per-device gate this leaks clients.
|
||||||
|
var tasks = Enumerable.Range(0, 32)
|
||||||
|
.Select(_ => Task.Run(() => drv.ReadAsync(["X"], CancellationToken.None)));
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
factory.Clients.Count.ShouldBe(1);
|
||||||
|
factory.Clients[0].ConnectCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Concurrent_reads_and_writes_share_one_client()
|
||||||
|
{
|
||||||
|
var factory = new FakeTwinCATClientFactory();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions(DeviceA)],
|
||||||
|
Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 1 } };
|
||||||
|
|
||||||
|
var work = new List<Task>();
|
||||||
|
for (var i = 0; i < 16; i++)
|
||||||
|
{
|
||||||
|
work.Add(Task.Run(() => drv.ReadAsync(["X"], CancellationToken.None)));
|
||||||
|
work.Add(Task.Run(() => drv.WriteAsync(
|
||||||
|
[new WriteRequest("X", 5)], CancellationToken.None)));
|
||||||
|
}
|
||||||
|
await Task.WhenAll(work);
|
||||||
|
|
||||||
|
factory.Clients.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.TwinCAT-013 — symbol-version-changed routes to IRediscoverable ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TwinCATDriver_implements_IRediscoverable()
|
||||||
|
{
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
|
||||||
|
drv.ShouldBeAssignableTo<IRediscoverable>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Symbol_version_changed_raises_OnRediscoveryNeeded()
|
||||||
|
{
|
||||||
|
var factory = new FakeTwinCATClientFactory();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions(DeviceA)],
|
||||||
|
Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
RediscoveryEventArgs? raised = null;
|
||||||
|
drv.OnRediscoveryNeeded += (_, args) => raised = args;
|
||||||
|
|
||||||
|
// Force a connect so the driver wires its handler onto the client.
|
||||||
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||||
|
factory.Clients.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
// The client observed ADS 0x0702 on the wire and signalled it.
|
||||||
|
factory.Clients[0].FireSymbolVersionChanged();
|
||||||
|
|
||||||
|
raised.ShouldNotBeNull();
|
||||||
|
raised!.Reason.ShouldContain("0x0702");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StatusMapper_recognises_symbol_version_changed_code()
|
||||||
|
{
|
||||||
|
TwinCATStatusMapper.AdsSymbolVersionChanged.ShouldBe(0x0702u);
|
||||||
|
TwinCATStatusMapper.IsSymbolVersionChanged(0x0702u).ShouldBeTrue();
|
||||||
|
TwinCATStatusMapper.IsSymbolVersionChanged(0u).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user