664ff75ee2
Read-only live gate on Elma presses Z35474 (TC3 3.1.4024) and Z34834 (TC2 2.11): - Sum-read: every chunk came back BadCommunicationError. Beckhoff's SumRead(IList<IgIoSumReadEntity>) ctor casts to IList<SumDataEntity>, which throws InvalidCastException for a List<> (only an array survives via covariance). The fake-client tests never built a real SumRead. New AdsTwinCATClient.CreateSumRead passes an array; TwinCATSumReadConstructionTests pins it (red on the List form) plus a falsifiability control on the library. After the fix: 59 tags, status identical everywhere, values identical on every non-moving tag, ~0.35-0.66 s vs ~2.7 s per poll; TC2 CE 2.11 answers sum reads. - An unexpected sum chunk failure now logs one Warning per device (it was invisible: the health message was overwritten by the next good read). - Diagnostics: TC2 has no TwinCAT_SystemInfoVarList; fall back to .SYSTEMINFO / .SYSTEMTASKINFOARR[1] once the TC3 name answers not-found (live: all six keys). - #612 browse: Flat verified on TC3 (10,770 leaves; VirtualTree 1,459 strict subset) — comment and docs updated, no load-mode option needed. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
938 lines
45 KiB
C#
938 lines
45 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Runtime.CompilerServices;
|
||
using System.Threading.Channels;
|
||
using TwinCAT;
|
||
using TwinCAT.Ads;
|
||
using TwinCAT.Ads.SumCommand;
|
||
using TwinCAT.Ads.TypeSystem;
|
||
using TwinCAT.TypeSystem;
|
||
|
||
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 const int NotificationQueueCapacity = 50_000;
|
||
|
||
private readonly AdsClient _client = 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;
|
||
private bool? _stringsAreUtf8;
|
||
|
||
/// <summary>
|
||
/// Initializes a new instance of the <see cref="AdsTwinCATClient"/> class.
|
||
/// </summary>
|
||
public AdsTwinCATClient()
|
||
{
|
||
_client.AdsNotificationEx += OnAdsNotificationEx;
|
||
_dispatchLoop = Task.Run(() => DispatchLoopAsync(_dispatchCts.Token));
|
||
}
|
||
|
||
private readonly record struct PendingNotification(NotificationRegistration Registration, object? Value);
|
||
|
||
/// <inheritdoc />
|
||
public bool IsConnected => _client.IsConnected;
|
||
|
||
/// <summary>
|
||
/// Occurs when the symbol version changes on the connected ADS target.
|
||
/// </summary>
|
||
public event EventHandler? OnSymbolVersionChanged;
|
||
|
||
/// <summary>Raise <see cref="OnSymbolVersionChanged"/> when <paramref name="adsError"/> is <c>DeviceSymbolVersionInvalid</c> (1809 / 0x0711).</summary>
|
||
private uint MapAndSignal(uint adsError)
|
||
{
|
||
if (TwinCATStatusMapper.IsSymbolVersionChanged(adsError))
|
||
OnSymbolVersionChanged?.Invoke(this, EventArgs.Empty);
|
||
return TwinCATStatusMapper.MapAdsError(adsError);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
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;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<(object? value, uint status)> ReadValueAsync(
|
||
string symbolPath,
|
||
TwinCATDataType type,
|
||
int? bitIndex,
|
||
int? arrayCount,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
// Bit-indexed BOOL — TwinCAT's symbol table doesn't expose "WordVar.N" as its
|
||
// own symbolic entry (ADS returns DeviceSymbolNotFound), so we read the parent
|
||
// container as its widest unsigned primitive and extract the bit locally. The
|
||
// .N suffix added by TwinCATSymbolPath.ToAdsSymbolName needs to come back off
|
||
// first. uint covers WORD / DWORD containers; BYTE-sized bit containers are
|
||
// rare in real code and promoting to uint is harmless for them. Bit-within-word
|
||
// is inherently scalar — arrayCount does not apply.
|
||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||
{
|
||
var parent = TwinCATBitAccess.ParentPath(symbolPath);
|
||
var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
|
||
.ConfigureAwait(false);
|
||
if (parentResult.ErrorCode != AdsErrorCode.NoError)
|
||
return (null, MapAndSignal((uint)parentResult.ErrorCode));
|
||
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
|
||
}
|
||
|
||
// Resolve the PLC symbol first so the declared type can be checked against it. The symbol is
|
||
// resolved through the Beckhoff symbol cache (the same lookup ReadValueAsync makes
|
||
// internally), so this costs no extra round trip after the first read.
|
||
var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
if (symbolStatus != TwinCATStatusMapper.Good) return (null, symbolStatus);
|
||
|
||
// Array read: raw bytes at the layout TwinCATTypeGuard.CheckArray plans from the RESOLVED
|
||
// array type, decoded by TwinCATArrayCodec — the same decode a native array notification
|
||
// runs, so poll and notification publish identical arrays. The Beckhoff typed array read this
|
||
// replaced failed for most arrays on a live TC 2.11 target: the CLR element type had to match
|
||
// exactly (ARRAY OF TIME as uint[] threw), a multi-dimensional array needed a rank-N CLR
|
||
// array, and a member array was sliced by its under-reported symbol size.
|
||
if (arrayCount is int count)
|
||
{
|
||
if (TwinCATTypeGuard.CheckArray(type, count, shape, array, out var layout) is not null)
|
||
return (null, TwinCATStatusMapper.BadTypeMismatch);
|
||
var (arrayBytes, arrayStatus) = await ReadRawAsync(symbolPath, symbol!, layout.ByteLength, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
return arrayStatus == TwinCATStatusMapper.Good
|
||
? (TwinCATArrayCodec.Decode(type, arrayBytes, layout, StringsAreUtf8()), TwinCATStatusMapper.Good)
|
||
: (null, arrayStatus);
|
||
}
|
||
if (TwinCATTypeGuard.CheckRead(type, shape) is not null)
|
||
return (null, TwinCATStatusMapper.BadTypeMismatch);
|
||
|
||
// A STRING / WSTRING is read as its raw bytes at the PLC's own declared width and
|
||
// decoded here: it ends at the first NUL, and a STRING is Windows-1252 rather than the
|
||
// host culture's ANSI code page the Beckhoff marshaller would fall back to. The width is
|
||
// the RESOLVED type's (shape.ByteSize) and the read is addressed explicitly: asking the
|
||
// library for typeof(byte[]) slices by the symbol's own ByteSize, which a TwinCAT 2
|
||
// member symbol under-reports (STRING(80) → 11 B, live on a TC 2.11 CX).
|
||
if (type is TwinCATDataType.String or TwinCATDataType.WString)
|
||
{
|
||
var (bytes, readStatus) = await ReadRawAsync(symbolPath, symbol!, shape.ByteSize, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
return readStatus == TwinCATStatusMapper.Good
|
||
? (DecodeString(type, bytes, StringsAreUtf8()), TwinCATStatusMapper.Good)
|
||
: (null, readStatus);
|
||
}
|
||
|
||
var clrType = MapToClrType(type);
|
||
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
|
||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||
return (null, MapAndSignal((uint)result.ErrorCode));
|
||
|
||
return (TwinCATTemporalCodec.IsTemporal(type) && result.Value is uint rawTemporal
|
||
? TwinCATTemporalCodec.Decode(type, rawTemporal)
|
||
: result.Value,
|
||
TwinCATStatusMapper.Good);
|
||
}
|
||
catch (AdsErrorException ex)
|
||
{
|
||
return (null, MapAndSignal((uint)ex.ErrorCode));
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
|
||
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
|
||
{
|
||
var results = new (object? value, uint status)[reads.Count];
|
||
var planned = new List<PlannedSumRead>(reads.Count);
|
||
|
||
Task<(object? value, uint status)> ReadSingly(int index) =>
|
||
ReadValueAsync(reads[index].SymbolPath, reads[index].Type, reads[index].BitIndex,
|
||
reads[index].ArrayCount, cancellationToken);
|
||
|
||
// Plan every item exactly as the single read would (symbol resolved from the Beckhoff symbol cache,
|
||
// declared type checked against it), but stop short of the wire read: eligible items become sum
|
||
// commands. Anything the sum command cannot carry exactly is read on its own right here.
|
||
for (var i = 0; i < reads.Count; i++)
|
||
{
|
||
var r = reads[i];
|
||
try
|
||
{
|
||
if (r.BitIndex is not null && r.Type == TwinCATDataType.Bool)
|
||
{
|
||
results[i] = await ReadSingly(i).ConfigureAwait(false);
|
||
continue;
|
||
}
|
||
|
||
var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(r.SymbolPath, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
if (symbolStatus != TwinCATStatusMapper.Good) { results[i] = (null, symbolStatus); continue; }
|
||
|
||
TwinCATArrayLayout layout;
|
||
if (r.ArrayCount is int count)
|
||
{
|
||
// Arrays are raw bytes on the single path too, with the same strict element rule.
|
||
if (TwinCATTypeGuard.CheckArray(r.Type, count, shape, array, out layout) is not null)
|
||
{
|
||
results[i] = (null, TwinCATStatusMapper.BadTypeMismatch);
|
||
continue;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (TwinCATTypeGuard.CheckRead(r.Type, shape) is not null)
|
||
{
|
||
results[i] = (null, TwinCATStatusMapper.BadTypeMismatch);
|
||
continue;
|
||
}
|
||
// The single read value-converts a numeric pair (DInt over a PLC INT is fine there); raw
|
||
// bytes decoded as the declared type would not. Only an exact width + kind match rides the
|
||
// sum command — the rest keeps the Beckhoff conversion by reading singly.
|
||
if (!TwinCATSumReadPlanner.ScalarEligible(r.Type, shape))
|
||
{
|
||
results[i] = await ReadSingly(i).ConfigureAwait(false);
|
||
continue;
|
||
}
|
||
// A scalar is a one-element "array" at the type's own width (a STRING at the PLC's declared
|
||
// width), so scalar and array share one raw decode.
|
||
layout = new TwinCATArrayLayout(1, r.Type is TwinCATDataType.String or TwinCATDataType.WString
|
||
? shape.ByteSize
|
||
: TwinCATTypeGuard.DeclaredByteSize(r.Type));
|
||
}
|
||
|
||
// A handle-addressed symbol (0xF005) needs a fresh variable handle per read — not expressible
|
||
// as an index-group/offset sum entry. Read it individually.
|
||
if (symbol!.IndexGroup == SymbolValueByHandle)
|
||
{
|
||
results[i] = await ReadSingly(i).ConfigureAwait(false);
|
||
continue;
|
||
}
|
||
|
||
planned.Add(new PlannedSumRead(i, symbol.IndexGroup, symbol.IndexOffset, layout, r.ArrayCount is not null));
|
||
}
|
||
catch (AdsErrorException ex)
|
||
{
|
||
results[i] = (null, MapAndSignal((uint)ex.ErrorCode));
|
||
}
|
||
}
|
||
|
||
if (planned.Count == 0) return results;
|
||
|
||
// Split to the byte budget; a lone item (or an oversized one) is just a read.
|
||
var groups = TwinCATSumReadPlanner.Partition(
|
||
[.. planned.Select(p => p.Layout.ByteLength)], TwinCATSumReadPlanner.MaxBatchBytes);
|
||
foreach (var group in groups)
|
||
{
|
||
var items = group.Select(g => planned[g]).ToList();
|
||
if (items.Count == 1)
|
||
{
|
||
results[items[0].Index] = await ReadSingly(items[0].Index).ConfigureAwait(false);
|
||
continue;
|
||
}
|
||
await ReadGroupAsync(items, reads, results, ReadSingly, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
return results;
|
||
}
|
||
|
||
private async Task ReadGroupAsync(
|
||
List<PlannedSumRead> items, IReadOnlyList<TwinCATBulkReadItem> reads,
|
||
(object? value, uint status)[] results,
|
||
Func<int, Task<(object? value, uint status)>> readSingly, CancellationToken cancellationToken)
|
||
{
|
||
async Task FallBackAsync()
|
||
{
|
||
foreach (var p in items) results[p.Index] = await readSingly(p.Index).ConfigureAwait(false);
|
||
}
|
||
|
||
ResultSumReadRaw sum;
|
||
try
|
||
{
|
||
sum = await CreateSumRead(_client, items.Select(p => (p.IndexGroup, p.IndexOffset, p.Layout.ByteLength)))
|
||
.ReadRawAsync(cancellationToken).ConfigureAwait(false);
|
||
}
|
||
catch (AdsErrorException ex) when (TwinCATSumReadPlanner.IsRefusal((uint)ex.ErrorCode))
|
||
{
|
||
throw new TwinCATSumReadRefusedException((uint)ex.ErrorCode, ex);
|
||
}
|
||
catch (ArgumentException)
|
||
{
|
||
// The library slices the response by the REQUESTED lengths and throws when a target answers with
|
||
// less. That is this target mangling this response, not a comms fault: read the group singly.
|
||
NoteSumFallback(0);
|
||
await FallBackAsync().ConfigureAwait(false);
|
||
return;
|
||
}
|
||
|
||
// On an overall failure the library RETURNS the code (SubErrors all set, ReadBlocks null).
|
||
if (sum.ErrorCode != AdsErrorCode.NoError)
|
||
{
|
||
var overall = (uint)sum.ErrorCode;
|
||
if (TwinCATSumReadPlanner.IsRefusal(overall))
|
||
throw new TwinCATSumReadRefusedException(overall);
|
||
if (TwinCATSumReadPlanner.IsRequestRejected(overall))
|
||
{
|
||
NoteSumFallback(overall);
|
||
await FallBackAsync().ConfigureAwait(false); // too big / bad size for THIS request: no latch
|
||
return;
|
||
}
|
||
var mapped = MapAndSignal(overall);
|
||
foreach (var p in items) results[p.Index] = (null, mapped);
|
||
return;
|
||
}
|
||
|
||
var utf8 = StringsAreUtf8();
|
||
for (var k = 0; k < items.Count; k++)
|
||
{
|
||
var p = items[k];
|
||
var subError = sum.SubErrors is { } errs && k < errs.Length ? errs[k] : AdsErrorCode.NoError;
|
||
ReadOnlyMemory<byte>? block = sum.ReadBlocks is { } blocks && k < blocks.Count ? blocks[k] : null;
|
||
results[p.Index] = TwinCATSumReadPlanner.DecodeItem(
|
||
reads[p.Index].Type, subError, block, p.Layout, p.IsArray, utf8, MapAndSignal);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds the index-group/offset ADS sum-read command for <paramref name="entries"/>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The entities MUST be handed over as an ARRAY. Beckhoff's
|
||
/// <c>SumRead(IAdsConnection, IList<IgIoSumReadEntity>, ...)</c> overload casts its argument to
|
||
/// <c>IList<SumDataEntity></c> internally; that cast succeeds for an array (array covariance) and
|
||
/// throws <see cref="InvalidCastException"/> for a <c>List<IgIoSumReadEntity></c>. The live gate
|
||
/// (hw2, 2026-10-08, TC3 Z35474) found every sum-read chunk failing that way — the driver's catch-all
|
||
/// turned it into <c>BadCommunicationError</c> for every batched tag on every poll — while the fake-client
|
||
/// unit tests never constructed a real <see cref="SumRead"/>. The constructor does no I/O, so
|
||
/// <c>TwinCATSumReadConstructionTests</c> pins this without a PLC.
|
||
/// </remarks>
|
||
/// <param name="connection">The ADS connection the command will run on.</param>
|
||
/// <param name="entries">Index group, index offset and byte length per item, in request order.</param>
|
||
/// <returns>A ready-to-run sum-read command (relaxed error strategy).</returns>
|
||
internal static SumRead CreateSumRead(
|
||
IAdsConnection connection, IEnumerable<(uint IndexGroup, uint IndexOffset, int ByteLength)> entries)
|
||
{
|
||
IgIoSumReadEntity[] entities = [.. entries.Select(e => new IgIoSumReadEntity(e.IndexGroup, e.IndexOffset, e.ByteLength))];
|
||
return new SumRead(connection, entities, SumCommandErrorStrategy.Relaxed);
|
||
}
|
||
|
||
private const uint SymbolValueByHandle = 0xF005;
|
||
|
||
private readonly record struct PlannedSumRead(int Index, uint IndexGroup, uint IndexOffset, TwinCATArrayLayout Layout, bool IsArray);
|
||
|
||
private string? _lastAdsState;
|
||
private long _sumFallbackCount;
|
||
private long _lastSumFallbackCode;
|
||
|
||
private void NoteSumFallback(uint code)
|
||
{
|
||
Interlocked.Exchange(ref _lastSumFallbackCode, code);
|
||
Interlocked.Increment(ref _sumFallbackCount);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public long SumFallbackCount => Interlocked.Read(ref _sumFallbackCount);
|
||
|
||
/// <inheritdoc />
|
||
public uint LastSumFallbackCode => (uint)Interlocked.Read(ref _lastSumFallbackCode);
|
||
|
||
/// <inheritdoc />
|
||
public string? LastAdsState => Volatile.Read(ref _lastAdsState);
|
||
|
||
/// <summary>
|
||
/// Resolves the PLC symbol behind <paramref name="symbolPath"/> into the facts
|
||
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
|
||
/// cache (lookup on), so repeat calls are local.
|
||
/// </summary>
|
||
private async Task<(TwinCATPlcSymbolShape shape, TwinCATPlcArrayShape? array, IAdsSymbol? symbol, uint status)>
|
||
DescribeSymbolAsync(string symbolPath, CancellationToken cancellationToken)
|
||
{
|
||
var result = await _client.ReadSymbolAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||
return (default, null, null, MapAndSignal((uint)result.ErrorCode));
|
||
if (result.Value is not { } symbol)
|
||
return (default, null, null, TwinCATStatusMapper.BadNodeIdUnknown);
|
||
|
||
// Resolve aliases (T_MaxString → STRING(255)) so the guard sees the base type, and take the
|
||
// resolved type's byte size — a TwinCAT 2 member symbol's own ByteSize is under-reported.
|
||
var dataType = symbol.DataType?.Resolve();
|
||
var shape = TwinCATPlcSymbolShape.Classify(
|
||
dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize, dataType?.ByteSize, symbol.TypeName);
|
||
return (shape, DescribeArray(dataType), symbol, TwinCATStatusMapper.Good);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The array facts <see cref="TwinCATTypeGuard.CheckArray"/> plans from: every dimension's element
|
||
/// count, the resolved array size, and the alias-resolved element type. Null for a non-array.
|
||
/// </summary>
|
||
private static TwinCATPlcArrayShape? DescribeArray(IDataType? dataType)
|
||
{
|
||
if (dataType is not IArrayType arrayType || arrayType.Dimensions is not { } dims) return null;
|
||
var element = arrayType.ElementType?.Resolve();
|
||
var elementShape = TwinCATPlcSymbolShape.Classify(
|
||
element?.Name ?? arrayType.ElementTypeName, element?.Category, element?.ByteSize ?? 0);
|
||
return new TwinCATPlcArrayShape([.. dims.Select(d => d.ElementCount)], dataType.ByteSize, elementShape);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads exactly <paramref name="length"/> raw bytes of a resolved symbol: by its
|
||
/// index group/offset, or — for a handle-addressed symbol (0xF005, whose offset is a handle
|
||
/// that may be stale) — through a fresh variable handle.
|
||
/// </summary>
|
||
private async Task<(byte[] bytes, uint status)> ReadRawAsync(
|
||
string symbolPath, IAdsSymbol symbol, int length, CancellationToken cancellationToken)
|
||
{
|
||
const uint SymbolValueByHandle = 0xF005;
|
||
if (symbol.IndexGroup != SymbolValueByHandle)
|
||
{
|
||
var direct = await _client.ReadAsync(symbol.IndexGroup, symbol.IndexOffset, length, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
return direct.ErrorCode == AdsErrorCode.NoError
|
||
? (direct.Data.ToArray(), TwinCATStatusMapper.Good)
|
||
: ([], MapAndSignal((uint)direct.ErrorCode));
|
||
}
|
||
|
||
var handle = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||
if (handle.ErrorCode != AdsErrorCode.NoError) return ([], MapAndSignal((uint)handle.ErrorCode));
|
||
try
|
||
{
|
||
var read = await _client.ReadAsync(handle.Handle, length, cancellationToken).ConfigureAwait(false);
|
||
return read.ErrorCode == AdsErrorCode.NoError
|
||
? (read.Data.ToArray(), TwinCATStatusMapper.Good)
|
||
: ([], MapAndSignal((uint)read.ErrorCode));
|
||
}
|
||
finally
|
||
{
|
||
try { await _client.DeleteVariableHandleAsync(handle.Handle, CancellationToken.None).ConfigureAwait(false); }
|
||
catch { /* best-effort release */ }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when the target reports UTF-8 STRING data (TwinCAT 3.1 4026+ "UTF8 Encoding for
|
||
/// STRING"); otherwise a STRING is Windows-1252. Read once per client from the symbol upload
|
||
/// info the Beckhoff client already fetches.
|
||
/// </summary>
|
||
private bool StringsAreUtf8()
|
||
{
|
||
if (_stringsAreUtf8 is bool known) return known;
|
||
try
|
||
{
|
||
var utf8 = _client.DefaultValueEncoding.CodePage == System.Text.Encoding.UTF8.CodePage;
|
||
_stringsAreUtf8 = utf8;
|
||
return utf8;
|
||
}
|
||
catch
|
||
{
|
||
return false; // not determinable yet (e.g. not connected) — Windows-1252, retry next time
|
||
}
|
||
}
|
||
|
||
private static string DecodeString(TwinCATDataType type, byte[] bytes, bool utf8) =>
|
||
type == TwinCATDataType.WString
|
||
? TwinCATStringCodec.DecodeWString(bytes)
|
||
: TwinCATStringCodec.DecodeString(bytes, utf8);
|
||
|
||
/// <inheritdoc />
|
||
public async Task<uint> WriteValueAsync(
|
||
string symbolPath,
|
||
TwinCATDataType type,
|
||
int? bitIndex,
|
||
object? value,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
var converted = ConvertForWrite(type, value);
|
||
var result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
return result.ErrorCode == AdsErrorCode.NoError
|
||
? TwinCATStatusMapper.Good
|
||
: MapAndSignal((uint)result.ErrorCode);
|
||
}
|
||
catch (AdsErrorException ex)
|
||
{
|
||
return MapAndSignal((uint)ex.ErrorCode);
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
var state = await _client.ReadStateAsync(cancellationToken).ConfigureAwait(false);
|
||
var ok = state.ErrorCode == AdsErrorCode.NoError;
|
||
Volatile.Write(ref _lastAdsState, ok ? state.State.AdsState.ToString() : null);
|
||
return ok;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||
string symbolPath,
|
||
TwinCATDataType type,
|
||
int? bitIndex,
|
||
int? arrayCount,
|
||
TimeSpan cycleTime,
|
||
int maxDelayMs,
|
||
Action<string, object?> onChange,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var clrType = MapToClrType(type);
|
||
int[]? anyTypeArgs = null;
|
||
var stringsUtf8 = false;
|
||
TwinCATArrayLayout? arrayLayout = null;
|
||
var registerPath = symbolPath;
|
||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||
{
|
||
// ADS has no symbol for WORD.N (live on TC 2.11: DeviceSymbolNotFound for .ADSSTATE_RUN.0,
|
||
// which took the whole subscribe call down). Register the PARENT word as raw bytes at its
|
||
// resolved width and read the bit in OnAdsNotificationEx — the read path's model.
|
||
registerPath = TwinCATBitAccess.ParentPath(symbolPath);
|
||
var (parent, _, _, parentStatus) = await DescribeSymbolAsync(registerPath, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
if (parentStatus != TwinCATStatusMapper.Good)
|
||
throw new InvalidOperationException(
|
||
$"Resolving {registerPath} for a notification failed with status 0x{parentStatus:X8}");
|
||
if (TwinCATBitAccess.CheckParent(bit, parent) is { } bitReason)
|
||
throw new TwinCATTypeMismatchException(symbolPath, bitReason);
|
||
clrType = typeof(byte[]);
|
||
anyTypeArgs = [parent.ByteSize];
|
||
}
|
||
else
|
||
{
|
||
// A notification carries raw bytes decoded as the DECLARED type, so a declared type that
|
||
// cannot decode the PLC symbol (width, float/integer or string kind differ) would publish
|
||
// wrong values at Good. Refuse it here; the driver isolates the one tag to Bad.
|
||
var (shape, array, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
if (symbolStatus != TwinCATStatusMapper.Good)
|
||
throw new InvalidOperationException(
|
||
$"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}");
|
||
|
||
if (arrayCount is int count)
|
||
{
|
||
// An array registers the whole planned layout (element stride × authored length, from the
|
||
// RESOLVED array type) as raw bytes and decodes it in OnAdsNotificationEx with the same
|
||
// TwinCATArrayCodec a poll read uses — so both paths publish the identical array.
|
||
if (TwinCATTypeGuard.CheckArray(type, count, shape, array, out var layout) is { } arrayReason)
|
||
throw new TwinCATTypeMismatchException(symbolPath, arrayReason);
|
||
if (TwinCATTypeGuard.CheckNotificationSize(layout) is { } sizeReason)
|
||
throw new TwinCATNotificationRefusedException(symbolPath, sizeReason, TwinCATStatusMapper.BadOutOfRange);
|
||
clrType = typeof(byte[]);
|
||
anyTypeArgs = [layout.ByteLength];
|
||
stringsUtf8 = StringsAreUtf8();
|
||
arrayLayout = layout;
|
||
}
|
||
else if (TwinCATTypeGuard.CheckNotification(type, shape) is { } reason)
|
||
{
|
||
throw new TwinCATTypeMismatchException(symbolPath, reason);
|
||
}
|
||
|
||
// A string has no fixed width: the "any" type string needs an explicit length (the
|
||
// Beckhoff marshaller throws ArgumentNullException without one), and a STRING(n) is n+1
|
||
// bytes with n chosen per declaration. Register the PLC's own byte width as raw bytes
|
||
// and decode them in OnAdsNotificationEx with the same rules as a read.
|
||
if (arrayLayout is null && type is TwinCATDataType.String or TwinCATDataType.WString)
|
||
{
|
||
clrType = typeof(byte[]);
|
||
anyTypeArgs = [shape.ByteSize];
|
||
stringsUtf8 = StringsAreUtf8();
|
||
}
|
||
}
|
||
|
||
var cycleMs = (int)Math.Max(1, cycleTime.TotalMilliseconds);
|
||
var settings = new NotificationSettings(AdsTransMode.OnChange, cycleMs, Math.Max(0, maxDelayMs));
|
||
|
||
// 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(
|
||
registerPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||
throw new InvalidOperationException(
|
||
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {registerPath}");
|
||
|
||
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
|
||
{
|
||
StringsUtf8 = stringsUtf8,
|
||
ArrayLayout = arrayLayout,
|
||
};
|
||
_notifications[result.Handle] = reg;
|
||
return reg;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap decode (bit
|
||
/// extraction, string, array) then enqueues — no driver logic, no consumer callbacks — so a slow consumer
|
||
/// can never stall ADS notification delivery for the rest of the process.
|
||
/// Drops the notification (DropWrite) if the queue is saturated.
|
||
/// </summary>
|
||
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)
|
||
{
|
||
if (value is not byte[] word || bit / 8 >= word.Length) return;
|
||
value = TwinCATBitAccess.ReadBit(word, bit);
|
||
}
|
||
else if (reg.ArrayLayout is { } layout && value is byte[] arrayBytes)
|
||
{
|
||
try { value = TwinCATArrayCodec.Decode(reg.Type, arrayBytes, layout, reg.StringsUtf8); }
|
||
catch (ArgumentException) { return; } // a short buffer is never published as a short array
|
||
}
|
||
else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString)
|
||
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
|
||
else if (TwinCATTemporalCodec.IsTemporal(reg.Type) && value is uint rawTemporal)
|
||
value = TwinCATTemporalCodec.Decode(reg.Type, rawTemporal);
|
||
_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.
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Deletes a notification subscription by handle.
|
||
/// </summary>
|
||
/// <param name="handle">The notification handle to delete.</param>
|
||
/// <param name="cancellationToken">The cancellation token.</param>
|
||
/// <returns>A task representing the asynchronous delete operation.</returns>
|
||
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 */ }
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
|
||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||
{
|
||
// SymbolLoaderFactory downloads the symbol-info blob once then iterates locally — the
|
||
// async surface on this interface is for our callers, not for the underlying call which
|
||
// is effectively sync on top of the already-open AdsClient.
|
||
//
|
||
// Flat is VERIFIED on TC3 (hw2 live gate 2026-10-08, Z35474, TwinCAT 3.1.4024): every struct
|
||
// root carried SubSymbols, 10,770 leaves; VirtualTree gave 1,459, a strict subset that drops
|
||
// array-of-struct element members. TC2 Flat was proven 2026-10-07. Do not switch to VirtualTree.
|
||
var settings = new SymbolLoaderSettings(SymbolsLoadMode.Flat);
|
||
var loader = SymbolLoaderFactory.Create(_client, settings);
|
||
await Task.Yield(); // honors the async surface; pragmatic given the loader itself is sync
|
||
|
||
// Expand struct/UDT/FB-instance symbols down to their addressable atomic members via the
|
||
// pure TwinCATSymbolExpander (unit-proven; the real ISymbol surface is non-injectable so the
|
||
// recursion is tested through the ITwinCATSymbolNode abstraction, then adapted here). The
|
||
// expander dedups by InstancePath, so it also neutralizes any Flat-vs-tree double-listing.
|
||
foreach (var ds in TwinCATSymbolExpander.ExpandLeaves(
|
||
loader.Symbols.Select(s => new AdsSymbolNode(s))))
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
yield return ds;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Adapts Beckhoff's <see cref="ISymbol"/> onto <see cref="ITwinCATSymbolNode"/> so the pure
|
||
/// <see cref="TwinCATSymbolExpander"/> can walk it. Children are materialized eagerly per node
|
||
/// (the loader has already downloaded the whole symbol-info blob, so descending sub-symbols is
|
||
/// a local operation). Reuses the existing <see cref="MapSymbolType"/> + <see cref="IsSymbolWritable"/>
|
||
/// so atomic-type mapping and access-rights handling stay identical to the pre-expansion path.
|
||
/// </summary>
|
||
private sealed class AdsSymbolNode : ITwinCATSymbolNode
|
||
{
|
||
// Computed once in the ctor so repeated property access during recursive expansion
|
||
// doesn't re-materialize Children or re-invoke MapSymbolType / IsSymbolWritable.
|
||
private readonly ISymbol _symbol;
|
||
private readonly bool _isStruct;
|
||
private readonly (TwinCATDataType? Type, int? ArrayLength) _mapped;
|
||
private readonly IReadOnlyList<ITwinCATSymbolNode> _children;
|
||
private readonly bool _readOnly;
|
||
|
||
/// <summary>
|
||
/// Initializes a new instance of the <see cref="AdsSymbolNode"/> class, computing its
|
||
/// struct/mapped-type/children/read-only state eagerly from the wrapped <paramref name="symbol"/>.
|
||
/// </summary>
|
||
/// <param name="symbol">The Beckhoff ADS symbol to adapt.</param>
|
||
public AdsSymbolNode(ISymbol symbol)
|
||
{
|
||
_symbol = symbol;
|
||
_isStruct = symbol.DataType?.Category == DataTypeCategory.Struct;
|
||
_mapped = MapSymbolType(symbol.DataType);
|
||
_children = symbol.SubSymbols is { } subs
|
||
? subs.Select(s => (ITwinCATSymbolNode)new AdsSymbolNode(s)).ToList()
|
||
: [];
|
||
_readOnly = !IsSymbolWritable(symbol);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public string InstancePath => _symbol.InstancePath;
|
||
|
||
/// <inheritdoc />
|
||
public bool IsStruct => _isStruct;
|
||
|
||
/// <inheritdoc />
|
||
public (TwinCATDataType? Type, int? ArrayLength) Mapped => _mapped;
|
||
|
||
/// <inheritdoc />
|
||
public IReadOnlyList<ITwinCATSymbolNode> Children => _children;
|
||
|
||
/// <inheritdoc />
|
||
public bool ReadOnly => _readOnly;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolves a symbol's <see cref="IDataType"/> to the driver's atomic
|
||
/// <see cref="TwinCATDataType"/> plus an optional 1-D array length.
|
||
/// <para>
|
||
/// A 1-D array symbol (<c>Category == Array</c>, <see cref="IArrayType.Dimensions"/> count 1)
|
||
/// surfaces as its ELEMENT type with the dimension's <see cref="IDimension.ElementCount"/> as
|
||
/// the array length. ASSUMPTION (no live ADS here): the array <see cref="IDataType"/> exposes
|
||
/// <see cref="IArrayType"/> with <see cref="IArrayType.ElementType"/> + a
|
||
/// <see cref="IArrayType.Dimensions"/> collection whose single <see cref="IDimension"/> carries
|
||
/// <see cref="IDimension.ElementCount"/> — matches the TwinCAT TypeSystem surface
|
||
/// (<c>TwinCAT.TypeSystem.IArrayType</c> / <c>IDimensionCollection.GetDimensionLengths()</c>).
|
||
/// Verified by unit test against the fake client only.
|
||
/// </para>
|
||
/// Multi-dimensional arrays (Dimensions.Count > 1, including jagged) are NOT in scope for
|
||
/// Phase 4c — they fall through as a scalar/unsupported <c>null</c> (which DiscoverAsync drops),
|
||
/// never silently mis-reported as a 1-D array.
|
||
/// </summary>
|
||
private static (TwinCATDataType? type, int? arrayLength) MapSymbolType(IDataType? dataType)
|
||
{
|
||
if (dataType is null) return (null, null);
|
||
|
||
if (dataType.Category == DataTypeCategory.Array && dataType is IArrayType arr)
|
||
{
|
||
// 1-D only. A multi-dim / jagged array is out of atomic scope → drop (null).
|
||
var dims = arr.Dimensions;
|
||
if (arr.IsJagged || dims is null || dims.Count != 1) return (null, null);
|
||
|
||
var element = MapSymbolTypeName(arr.ElementType?.Name);
|
||
if (element is null) return (null, null); // element type is itself unsupported (UDT array etc.)
|
||
|
||
var length = dims[0].ElementCount;
|
||
return length > 0 ? (element, length) : (null, null);
|
||
}
|
||
|
||
return (MapSymbolTypeName(dataType.Name), null);
|
||
}
|
||
|
||
internal static TwinCATDataType? MapSymbolTypeName(string? typeName)
|
||
{
|
||
if (typeName is null) return null;
|
||
// A TwinCAT 2 data-type upload pads some member type names with a trailing blank
|
||
// ("REAL ", "DINT " on a TC 2.11 CX) — trim before matching.
|
||
var name = typeName.Trim();
|
||
// SymbolLoader emits STRING(80) / WSTRING(80) with the declared bound baked into
|
||
// the type name — strip the "(...)" suffix so sized strings map onto the bare
|
||
// String/WString atom the driver speaks. Only for the string kinds: any other
|
||
// parenthesised form ("INT (VAR_IN_OUT)" — a 4-byte reference on TC2) is not the
|
||
// scalar its prefix names and stays unsupported.
|
||
var paren = name.IndexOf('(');
|
||
var bare = paren > 0 ? name[..paren] : name;
|
||
if (paren > 0 && bare is not ("STRING" or "WSTRING")) return null;
|
||
return bare switch
|
||
{
|
||
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
||
"SINT" or "BYTE" => TwinCATDataType.SInt,
|
||
// TwinCAT 2 reports top-level globals by their ADS base-type name (INT16 / UINT16 / …),
|
||
// and the Beckhoff type system keeps that spelling as a built-in primitive
|
||
// (AdsSymbolProvider.CreateBuildInTypes) — map each like its IEC twin.
|
||
"USINT" or "UINT8" => TwinCATDataType.USInt,
|
||
"INT" or "WORD" or "INT16" => TwinCATDataType.Int,
|
||
"UINT" or "UINT16" => TwinCATDataType.UInt,
|
||
"DINT" or "DWORD" or "INT32" => TwinCATDataType.DInt,
|
||
"UDINT" or "UINT32" => TwinCATDataType.UDInt,
|
||
"LINT" or "LWORD" => TwinCATDataType.LInt,
|
||
"ULINT" => TwinCATDataType.ULInt,
|
||
"REAL" or "FLOAT" => TwinCATDataType.Real,
|
||
"LREAL" or "DOUBLE" => TwinCATDataType.LReal,
|
||
"STRING" => TwinCATDataType.String,
|
||
"WSTRING" => TwinCATDataType.WString,
|
||
"TIME" => TwinCATDataType.Time,
|
||
"DATE" => TwinCATDataType.Date,
|
||
"DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime,
|
||
"TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay,
|
||
_ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope
|
||
};
|
||
}
|
||
|
||
private static bool IsSymbolWritable(ISymbol symbol)
|
||
{
|
||
// SymbolAccessRights is a flags enum — the Write bit indicates a writable symbol.
|
||
// When the symbol implementation doesn't surface it, assume writable + let the PLC
|
||
// return AccessDenied at write time.
|
||
if (symbol is Symbol s) return (s.AccessRights & SymbolAccessRights.Write) != 0;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Disposes the client and releases all resources.
|
||
/// </summary>
|
||
public void Dispose()
|
||
{
|
||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||
_client.AdsNotificationEx -= OnAdsNotificationEx;
|
||
_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();
|
||
}
|
||
|
||
private sealed class NotificationRegistration(
|
||
string symbolPath,
|
||
TwinCATDataType type,
|
||
int? bitIndex,
|
||
Action<string, object?> onChange,
|
||
AdsTwinCATClient owner,
|
||
uint handle) : ITwinCATNotificationHandle
|
||
{
|
||
/// <summary>
|
||
/// Gets the symbol path being monitored.
|
||
/// </summary>
|
||
public string SymbolPath { get; } = symbolPath;
|
||
|
||
/// <summary>
|
||
/// Gets the TwinCAT data type of the symbol.
|
||
/// </summary>
|
||
public TwinCATDataType Type { get; } = type;
|
||
|
||
/// <summary>
|
||
/// Gets the optional bit index for BOOL values.
|
||
/// </summary>
|
||
public int? BitIndex { get; } = bitIndex;
|
||
|
||
/// <summary>
|
||
/// Gets the callback to invoke when the value changes.
|
||
/// </summary>
|
||
public Action<string, object?> OnChange { get; } = onChange;
|
||
|
||
/// <summary>
|
||
/// Gets a value indicating whether a STRING notification decodes as UTF-8 (else Windows-1252).
|
||
/// </summary>
|
||
public bool StringsUtf8 { get; init; }
|
||
|
||
/// <summary>
|
||
/// Gets the raw-bytes layout of an array notification, decoded by <see cref="TwinCATArrayCodec"/>; null for a scalar.
|
||
/// </summary>
|
||
public TwinCATArrayLayout? ArrayLayout { get; init; }
|
||
|
||
/// <summary>
|
||
/// Disposes the notification subscription.
|
||
/// </summary>
|
||
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),
|
||
};
|
||
|
||
internal 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 => TwinCATTemporalCodec.Encode(type, 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
|
||
{
|
||
/// <inheritdoc />
|
||
public ITwinCATClient Create() => new AdsTwinCATClient();
|
||
}
|