Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/Z21.Client.UnitTest/Core/SafeCvProgrammingTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,5 +254,73 @@ public void Operation_AfterDispose_ThrowsObjectDisposed()

Assert.ThrowsAsync<ObjectDisposedException>(async () => await _station.ReadCvAsync(5, TimeSpan.FromSeconds(2)));
}

[Test]
public async Task WritePomCvBitAsync_ReadBackBitMatchesCompletes()
{
Task task = _station.WritePomCvBitAsync(3, 5, 2, true, TimeSpan.FromSeconds(2));

await WaitForSentAsync(2); // POM write-bit + POM read-back
RaiseResult(5, 0b0000_0100); // bit 2 set -> matches

await task;
Assert.That(_transport.Sent, Has.Count.EqualTo(2));
}

[Test]
public async Task WritePomCvBitAsync_RetriesUntilBitMatches()
{
Task task = _station.WritePomCvBitAsync(3, 5, 2, true, TimeSpan.FromSeconds(5));

await WaitForSentAsync(2); // write-bit + read-back
RaiseResult(5, 0b0000_0000); // bit 2 clear -> rewrite + reread
await WaitForSentAsync(4);
RaiseResult(5, 0b0000_0100); // bit 2 set -> done

await task;
Assert.That(_transport.Sent, Has.Count.EqualTo(4));
}

[Test]
public void WritePomCvBitAsync_NoReplyThrowsTimeout()
{
Task task = _station.WritePomCvBitAsync(3, 5, 2, true, TimeSpan.FromMilliseconds(100));

Assert.ThrowsAsync<CvOperationTimeoutException>(async () => await task);
}

[Test]
public void WritePomCvBitAsync_InvalidBitPositionThrows() =>
Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () => await _station.WritePomCvBitAsync(3, 5, 8, true, TimeSpan.FromSeconds(2)));

[Test]
public async Task ReadPomCvBitAsync_ReturnsSetBit()
{
Task<bool> task = _station.ReadPomCvBitAsync(3, 5, 2, TimeSpan.FromSeconds(2));
RaiseResult(5, 0b0000_0100);

Assert.That(await task, Is.True);
}

[Test]
public async Task ReadPomCvBitAsync_ReturnsClearBit()
{
Task<bool> task = _station.ReadPomCvBitAsync(3, 5, 1, TimeSpan.FromSeconds(2));
RaiseResult(5, 0b0000_0100);

Assert.That(await task, Is.False);
}

[Test]
public void ReadPomCvBitAsync_NoReplyThrowsTimeout()
{
Task<bool> task = _station.ReadPomCvBitAsync(3, 5, 2, TimeSpan.FromMilliseconds(100));

Assert.ThrowsAsync<CvOperationTimeoutException>(async () => await task);
}

[Test]
public void ReadPomCvBitAsync_InvalidBitPositionThrows() =>
Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () => await _station.ReadPomCvBitAsync(3, 5, 8, TimeSpan.FromSeconds(2)));
}
}
22 changes: 22 additions & 0 deletions src/Z21.Client/Core/IZ21CommandStation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,27 @@ public interface IZ21CommandStation : ICommandStation, ILocoControl, IAccessoryC
/// <exception cref="CvShortCircuitException">The command station reported a short circuit.</exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="timeout"/> is not a positive, in-range duration.</exception>
Task WritePomCvAsync(ushort locoAddress, ushort cvAddress, byte value, TimeSpan timeout);

/// <summary>
/// Writes a single bit of a CV of a locomotive decoder on the main track (POM). Because a POM write
/// returns no acknowledgement, this verifies by reading the CV byte back and retrying until the
/// target bit matches the written value (so it requires RailCom). A bit that never reads back is
/// reported as a timeout. Do not run other CV operations on this station concurrently.
/// </summary>
/// <param name="bitPosition">The 0-based bit position within the CV (0–7).</param>
/// <exception cref="CvOperationTimeoutException">The write could not be confirmed within <paramref name="timeout"/>.</exception>
/// <exception cref="CvShortCircuitException">The command station reported a short circuit.</exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="bitPosition"/> exceeds 7, or <paramref name="timeout"/> is not a positive, in-range duration.</exception>
Task WritePomCvBitAsync(ushort locoAddress, ushort cvAddress, byte bitPosition, bool bitValue, TimeSpan timeout);

/// <summary>
/// Reads a single bit of a CV of a locomotive decoder on the main track (POM) by reading the CV byte
/// back (so it requires RailCom) and returning the value of the target bit.
/// </summary>
/// <param name="bitPosition">The 0-based bit position within the CV (0–7).</param>
/// <exception cref="CvOperationTimeoutException">No result arrived within <paramref name="timeout"/>.</exception>
/// <exception cref="CvShortCircuitException">The command station reported a short circuit.</exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="bitPosition"/> exceeds 7, or <paramref name="timeout"/> is not a positive, in-range duration.</exception>
Task<bool> ReadPomCvBitAsync(ushort locoAddress, ushort cvAddress, byte bitPosition, TimeSpan timeout);
}
}
33 changes: 33 additions & 0 deletions src/Z21.Client/Core/Z21CommandStation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,33 @@ private async Task<byte> WritePomCvCoreAsync(ushort locoAddress, ushort cvAddres
}
}

public async Task WritePomCvBitAsync(ushort locoAddress, ushort cvAddress, byte bitPosition, bool bitValue, TimeSpan timeout)
{
ValidateBitPosition(bitPosition);
await RunUnderCvLockAsync(cvAddress, timeout,
token => WritePomCvBitCoreAsync(locoAddress, cvAddress, bitPosition, bitValue, token, timeout));
}

public async Task<bool> ReadPomCvBitAsync(ushort locoAddress, ushort cvAddress, byte bitPosition, TimeSpan timeout)
{
ValidateBitPosition(bitPosition);
byte value = await ReadPomCvAsync(locoAddress, cvAddress, timeout);
return ((value >> bitPosition) & 1) == 1;
}

private async Task<byte> WritePomCvBitCoreAsync(ushort locoAddress, ushort cvAddress, byte bitPosition, bool bitValue, CancellationToken token, TimeSpan timeout)
{
while (true)
{
await SendCommandsAsync(Commands.Create<CvPomWriteBitCommand>(locoAddress, cvAddress, bitPosition, bitValue)).WaitAsync(token);
byte readBack = await AwaitResultLoopAsync(cvAddress, () => SendCommandsAsync(Commands.Create<CvPomReadByteCommand>(locoAddress, cvAddress)), token, timeout);
if ((((readBack >> bitPosition) & 1) == 1) == bitValue)
return readBack;

await DelayBeforeRetryAsync(cvAddress, token, timeout); // read-back bit mismatch -> wait, then re-write
}
}

private async Task<byte> RunUnderCvLockAsync(ushort cvAddress, TimeSpan timeout, Func<CancellationToken, Task<byte>> operation)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Expand Down Expand Up @@ -336,6 +363,12 @@ private void ValidateTimeout(TimeSpan timeout)
throw new ArgumentOutOfRangeException(nameof(timeout), timeout, $"The CV operation timeout must not exceed {int.MaxValue} milliseconds.");
}

private void ValidateBitPosition(byte bitPosition)
{
if (bitPosition > 7)
throw new ArgumentOutOfRangeException(nameof(bitPosition), bitPosition, "The CV bit position must be between 0 and 7.");
}

private System.Exception MapCancellation(ushort cvAddress, TimeSpan timeout) =>
_disposeCts.IsCancellationRequested
? new ObjectDisposedException(GetType().FullName)
Expand Down
Loading