From cd8fc320ba3d6ffd14dfdf5d5cbefd0ac83e84f0 Mon Sep 17 00:00:00 2001 From: Jakob Date: Sat, 6 Jun 2026 01:04:17 +0200 Subject: [PATCH] Add safe retrying POM bit read and write methods --- .../Core/SafeCvProgrammingTest.cs | 68 +++++++++++++++++++ src/Z21.Client/Core/IZ21CommandStation.cs | 22 ++++++ src/Z21.Client/Core/Z21CommandStation.cs | 33 +++++++++ 3 files changed, 123 insertions(+) diff --git a/src/Z21.Client.UnitTest/Core/SafeCvProgrammingTest.cs b/src/Z21.Client.UnitTest/Core/SafeCvProgrammingTest.cs index f4da533..3245071 100644 --- a/src/Z21.Client.UnitTest/Core/SafeCvProgrammingTest.cs +++ b/src/Z21.Client.UnitTest/Core/SafeCvProgrammingTest.cs @@ -254,5 +254,73 @@ public void Operation_AfterDispose_ThrowsObjectDisposed() Assert.ThrowsAsync(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(async () => await task); + } + + [Test] + public void WritePomCvBitAsync_InvalidBitPositionThrows() => + Assert.ThrowsAsync(async () => await _station.WritePomCvBitAsync(3, 5, 8, true, TimeSpan.FromSeconds(2))); + + [Test] + public async Task ReadPomCvBitAsync_ReturnsSetBit() + { + Task 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 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 task = _station.ReadPomCvBitAsync(3, 5, 2, TimeSpan.FromMilliseconds(100)); + + Assert.ThrowsAsync(async () => await task); + } + + [Test] + public void ReadPomCvBitAsync_InvalidBitPositionThrows() => + Assert.ThrowsAsync(async () => await _station.ReadPomCvBitAsync(3, 5, 8, TimeSpan.FromSeconds(2))); } } diff --git a/src/Z21.Client/Core/IZ21CommandStation.cs b/src/Z21.Client/Core/IZ21CommandStation.cs index eb360c1..30fb593 100644 --- a/src/Z21.Client/Core/IZ21CommandStation.cs +++ b/src/Z21.Client/Core/IZ21CommandStation.cs @@ -42,5 +42,27 @@ public interface IZ21CommandStation : ICommandStation, ILocoControl, IAccessoryC /// The command station reported a short circuit. /// is not a positive, in-range duration. Task WritePomCvAsync(ushort locoAddress, ushort cvAddress, byte value, TimeSpan timeout); + + /// + /// 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. + /// + /// The 0-based bit position within the CV (0–7). + /// The write could not be confirmed within . + /// The command station reported a short circuit. + /// exceeds 7, or is not a positive, in-range duration. + Task WritePomCvBitAsync(ushort locoAddress, ushort cvAddress, byte bitPosition, bool bitValue, TimeSpan timeout); + + /// + /// 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. + /// + /// The 0-based bit position within the CV (0–7). + /// No result arrived within . + /// The command station reported a short circuit. + /// exceeds 7, or is not a positive, in-range duration. + Task ReadPomCvBitAsync(ushort locoAddress, ushort cvAddress, byte bitPosition, TimeSpan timeout); } } diff --git a/src/Z21.Client/Core/Z21CommandStation.cs b/src/Z21.Client/Core/Z21CommandStation.cs index cc6ce45..7f55247 100644 --- a/src/Z21.Client/Core/Z21CommandStation.cs +++ b/src/Z21.Client/Core/Z21CommandStation.cs @@ -221,6 +221,33 @@ private async Task 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 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 WritePomCvBitCoreAsync(ushort locoAddress, ushort cvAddress, byte bitPosition, bool bitValue, CancellationToken token, TimeSpan timeout) + { + while (true) + { + await SendCommandsAsync(Commands.Create(locoAddress, cvAddress, bitPosition, bitValue)).WaitAsync(token); + byte readBack = await AwaitResultLoopAsync(cvAddress, () => SendCommandsAsync(Commands.Create(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 RunUnderCvLockAsync(ushort cvAddress, TimeSpan timeout, Func> operation) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -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)