Auto: twincat-1.3 — bit-indexed BOOL writes (RMW)
Replace the NotSupportedException at AdsTwinCATClient.WriteValueAsync for bit-indexed BOOL writes with a read-modify-write path: 1. Strip the trailing .N selector from the symbol path. 2. Read the parent as UDINT. 3. Set or clear bit N via the standard mask. 4. Write the parent back. Concurrent bit writers against the same parent serialise through a per-parent SemaphoreSlim cached in a ConcurrentDictionary (never removed — bounded by writable-bit-tag cardinality). Mirrors the AbCip / Modbus / FOCAS bit-RMW pattern shipped in #181 pass 1. The path-stripping (TryGetParentSymbolPath) and mask helper (ApplyBit) are exposed as internal statics so tests can pin the pure logic without needing a real ADS target. The FakeTwinCATClient mirrors the same RMW semantics so driver-level round-trip tests assert the parent-word state. Closes #307
This commit is contained in:
@@ -24,6 +24,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
private readonly AdsClient _client = new();
|
||||
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
|
||||
|
||||
// Per-parent-symbol RMW locks. Keys are bounded by the writable-bit-tag cardinality
|
||||
// and are intentionally never removed — a leaking-but-bounded dictionary is simpler
|
||||
// than tracking liveness, matching the AbCip / Modbus / FOCAS pattern from #181.
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _bitWriteLocks = new();
|
||||
|
||||
public AdsTwinCATClient()
|
||||
{
|
||||
_client.AdsNotificationEx += OnAdsNotificationEx;
|
||||
@@ -75,9 +80,9 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
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.");
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||
return await WriteBitInWordAsync(symbolPath, bit, value, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -94,6 +99,69 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write a single bit within an integer parent word. <paramref name="symbolPath"/>
|
||||
/// is the bit-selector path (e.g. <c>Flags.3</c>); the parent is the same path with the
|
||||
/// <c>.N</c> suffix stripped and is read/written as a UDINT — TwinCAT handles narrower
|
||||
/// parents (BYTE/WORD) implicitly through the UDINT projection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Concurrent bit writers against the same parent are serialised through a per-parent
|
||||
/// <see cref="SemaphoreSlim"/> to prevent torn reads/writes. Mirrors the AbCip / Modbus /
|
||||
/// FOCAS bit-RMW pattern.
|
||||
/// </remarks>
|
||||
private async Task<uint> WriteBitInWordAsync(
|
||||
string symbolPath, int bit, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
var parentPath = TryGetParentSymbolPath(symbolPath);
|
||||
if (parentPath is null) return TwinCATStatusMapper.BadNotSupported;
|
||||
|
||||
var setBit = Convert.ToBoolean(value);
|
||||
var rmwLock = _bitWriteLocks.GetOrAdd(parentPath, _ => new SemaphoreSlim(1, 1));
|
||||
await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var read = await _client.ReadValueAsync(parentPath, typeof(uint), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read.ErrorCode != AdsErrorCode.NoError)
|
||||
return TwinCATStatusMapper.MapAdsError((uint)read.ErrorCode);
|
||||
|
||||
var current = Convert.ToUInt32(read.Value ?? 0u);
|
||||
var updated = ApplyBit(current, bit, setBit);
|
||||
|
||||
var write = await _client.WriteValueAsync(parentPath, updated, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return write.ErrorCode == AdsErrorCode.NoError
|
||||
? TwinCATStatusMapper.Good
|
||||
: TwinCATStatusMapper.MapAdsError((uint)write.ErrorCode);
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strip the trailing <c>.N</c> bit selector from a TwinCAT symbol path. Returns
|
||||
/// <c>null</c> when the path has no parent (single segment / leading dot).
|
||||
/// </summary>
|
||||
internal static string? TryGetParentSymbolPath(string symbolPath)
|
||||
{
|
||||
var dot = symbolPath.LastIndexOf('.');
|
||||
return dot <= 0 ? null : symbolPath.Substring(0, dot);
|
||||
}
|
||||
|
||||
/// <summary>Set or clear bit <paramref name="bit"/> in <paramref name="word"/>.</summary>
|
||||
internal static uint ApplyBit(uint word, int bit, bool setBit)
|
||||
{
|
||||
var mask = 1u << bit;
|
||||
return setBit ? (word | mask) : (word & ~mask);
|
||||
}
|
||||
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user