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:
Joseph Doherty
2026-04-25 17:22:59 -04:00
parent f83c467647
commit fcf89618cd
3 changed files with 195 additions and 4 deletions

View File

@@ -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