diff --git a/src/CommandStation.Abstractions/CvOperationTimeoutException.cs b/src/CommandStation.Abstractions/CvOperationTimeoutException.cs new file mode 100644 index 0000000..e6ec56f --- /dev/null +++ b/src/CommandStation.Abstractions/CvOperationTimeoutException.cs @@ -0,0 +1,24 @@ +using System; + +namespace CommandStation +{ + /// + /// Thrown when a safe CV programming operation does not complete within the caller-supplied timeout + /// (for example, the decoder never acknowledges so the operation keeps retrying until the deadline). + /// + public sealed class CvOperationTimeoutException : Exception + { + /// The 0-based CV address the operation targeted (0 = CV1). + public ushort CvAddress { get; } + + /// The timeout that elapsed before a result was received. + public TimeSpan Timeout { get; } + + public CvOperationTimeoutException(ushort cvAddress, TimeSpan timeout) + : base($"CV {cvAddress + 1} did not complete within {timeout.TotalSeconds:0.###}s.") + { + CvAddress = cvAddress; + Timeout = timeout; + } + } +} diff --git a/src/CommandStation.Abstractions/CvShortCircuitException.cs b/src/CommandStation.Abstractions/CvShortCircuitException.cs new file mode 100644 index 0000000..1922e8e --- /dev/null +++ b/src/CommandStation.Abstractions/CvShortCircuitException.cs @@ -0,0 +1,20 @@ +using System; + +namespace CommandStation +{ + /// + /// Thrown when a CV programming operation is aborted because the command station reported a short + /// circuit on the track. The operation is not retried. + /// + public sealed class CvShortCircuitException : Exception + { + /// The 0-based CV address the operation targeted (0 = CV1). + public ushort CvAddress { get; } + + public CvShortCircuitException(ushort cvAddress) + : base($"CV programming aborted: short circuit on the track (CV {cvAddress + 1}).") + { + CvAddress = cvAddress; + } + } +} diff --git a/src/CommandStation.Abstractions/IProgrammingControl.cs b/src/CommandStation.Abstractions/IProgrammingControl.cs index 9b85896..96ec3ff 100644 --- a/src/CommandStation.Abstractions/IProgrammingControl.cs +++ b/src/CommandStation.Abstractions/IProgrammingControl.cs @@ -14,6 +14,21 @@ public interface IProgrammingControl Task WriteCvAsync(ushort cvAddress, byte value); + /// + /// Reads a CV on the programming track, retrying while the decoder does not acknowledge, and + /// returns the value. Throws if no result arrives within + /// , or on a short circuit. + /// + Task ReadCvAsync(ushort cvAddress, TimeSpan timeout); + + /// + /// Writes a CV on the programming track, retrying while the decoder does not acknowledge, and + /// completes once the command station confirms the write. Throws + /// if not confirmed within , or + /// on a short circuit. + /// + Task WriteCvAsync(ushort cvAddress, byte value, TimeSpan timeout); + event EventHandler? CvReadCompleted; event EventHandler? CvProgrammingFailed; diff --git a/src/Z21.Client.UnitTest/Core/SafeCvProgrammingTest.cs b/src/Z21.Client.UnitTest/Core/SafeCvProgrammingTest.cs new file mode 100644 index 0000000..8af7b12 --- /dev/null +++ b/src/Z21.Client.UnitTest/Core/SafeCvProgrammingTest.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using CommandStation; +using Moq; +using Z21.Core; +using Z21.Core.Codecs; +using Z21.Core.Command; +using Z21.Core.Framing; +using Z21.Core.Model.EventArgs; +using Z21.Core.ResponseHandler; +using Z21.Core.ResponseHandler.Driving; +using Z21.Core.ResponseHandler.FastClock; +using Z21.Core.ResponseHandler.Feedback; +using Z21.Core.ResponseHandler.Programming; +using Z21.Core.ResponseHandler.Switching; +using Z21.Core.ResponseHandler.SystemState; +using Z21.Core.ResponseHandler.SystemState.TrackPower; + +namespace Z21.UnitTest.Core +{ + public class SafeCvProgrammingTest + { + private FakeTransport _transport = null!; + private Mock _cvResult = null!; + private Mock _cvNack = null!; + private Mock _cvNackSc = null!; + private Z21CommandStation _station = null!; + + [SetUp] + public void SetUp() + { + _transport = new FakeTransport(); + Z21CommandFactory factory = new(new Z21FrameBuilder(), new AddressCodec(), new LocoSpeedCodec()); + _cvResult = new Mock(); + _cvNack = new Mock(); + _cvNackSc = new Mock(); + Z21ResponseHandler dispatcher = new(_transport, new Z21FrameReader(), new List()); + + _station = new Z21CommandStation( + _transport, + dispatcher, + factory, + new Z21Options(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + _cvResult.Object, + _cvNack.Object, + _cvNackSc.Object, + Mock.Of(), + Mock.Of()); + + _transport.SetConnected(true); + } + + [TearDown] + public void TearDown() => _station.Dispose(); + + private void RaiseResult(ushort cvAddress, byte value) => + _cvResult.Raise(h => h.OnCvResultReceived += null, new CvResultReceivedEventArgs(cvAddress, value)); + + private void RaiseNack() => _cvNack.Raise(h => h.OnCvNackReceived += null, EventArgs.Empty); + + private void RaiseShortCircuit() => _cvNackSc.Raise(h => h.OnCvNackShortCircuitReceived += null, EventArgs.Empty); + + private async Task WaitForSentAsync(int count) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + while (_transport.Sent.Count < count) + { + if (stopwatch.Elapsed > TimeSpan.FromSeconds(2)) + throw new TimeoutException($"Expected {count} sent datagrams, saw {_transport.Sent.Count}."); + await Task.Delay(5); + } + } + + [Test] + public async Task ReadCvAsync_ResultReturnsValue() + { + Task task = _station.ReadCvAsync(5, TimeSpan.FromSeconds(2)); + RaiseResult(5, 42); + + byte value = await task; + Assert.Multiple(() => + { + Assert.That(value, Is.EqualTo(42)); + Assert.That(_transport.Sent, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async Task ReadCvAsync_RetriesOnNackThenReturnsValue() + { + Task task = _station.ReadCvAsync(5, TimeSpan.FromSeconds(5)); + + RaiseNack(); + await WaitForSentAsync(2); + RaiseResult(5, 99); + + byte value = await task; + Assert.Multiple(() => + { + Assert.That(value, Is.EqualTo(99)); + Assert.That(_transport.Sent, Has.Count.EqualTo(2)); + }); + } + + [Test] + public void ReadCvAsync_ShortCircuitThrowsAndDoesNotRetry() + { + Task task = _station.ReadCvAsync(5, TimeSpan.FromSeconds(2)); + RaiseShortCircuit(); + + Assert.ThrowsAsync(async () => await task); + Assert.That(_transport.Sent, Has.Count.EqualTo(1)); + } + + [Test] + public void ReadCvAsync_NoResponseThrowsTimeout() + { + Task task = _station.ReadCvAsync(5, TimeSpan.FromMilliseconds(100)); + + Assert.ThrowsAsync(async () => await task); + } + + [Test] + public async Task WriteCvAsync_ResultCompletes() + { + Task task = _station.WriteCvAsync(7, 200, TimeSpan.FromSeconds(2)); + RaiseResult(7, 200); + + await task; + Assert.That(_transport.Sent, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WriteCvAsync_RetriesOnNackThenCompletes() + { + Task task = _station.WriteCvAsync(7, 200, TimeSpan.FromSeconds(5)); + + RaiseNack(); + await WaitForSentAsync(2); + RaiseResult(7, 200); + + await task; + Assert.That(_transport.Sent, Has.Count.EqualTo(2)); + } + + [Test] + public void WriteCvAsync_NoResponseThrowsTimeout() + { + Task task = _station.WriteCvAsync(7, 200, TimeSpan.FromMilliseconds(100)); + + Assert.ThrowsAsync(async () => await task); + } + + [Test] + public async Task ReadPomCvAsync_ResultReturnsValue() + { + Task task = _station.ReadPomCvAsync(3, 5, TimeSpan.FromSeconds(2)); + RaiseResult(5, 17); + + Assert.That(await task, Is.EqualTo(17)); + } + + [Test] + public void ReadPomCvAsync_NoRailComReplyThrowsTimeout() + { + Task task = _station.ReadPomCvAsync(3, 5, TimeSpan.FromMilliseconds(100)); + + Assert.ThrowsAsync(async () => await task); + } + + [Test] + public async Task WritePomCvAsync_ReadBackMatchesCompletes() + { + Task task = _station.WritePomCvAsync(3, 5, 50, TimeSpan.FromSeconds(2)); + + await WaitForSentAsync(2); // POM write + POM read-back + RaiseResult(5, 50); + + await task; + Assert.That(_transport.Sent, Has.Count.EqualTo(2)); + } + + [Test] + public async Task WritePomCvAsync_RetriesUntilReadBackMatches() + { + Task task = _station.WritePomCvAsync(3, 5, 50, TimeSpan.FromSeconds(5)); + + await WaitForSentAsync(2); // write + read-back + RaiseResult(5, 13); // read-back mismatch -> rewrite + reread + await WaitForSentAsync(4); + RaiseResult(5, 50); // matches -> done + + await task; + Assert.That(_transport.Sent, Has.Count.EqualTo(4)); + } + + [Test] + public void WritePomCvAsync_NoReplyThrowsTimeout() + { + Task task = _station.WritePomCvAsync(3, 5, 50, TimeSpan.FromMilliseconds(100)); + + Assert.ThrowsAsync(async () => await task); + } + } +} diff --git a/src/Z21.Client/Core/IZ21CommandStation.cs b/src/Z21.Client/Core/IZ21CommandStation.cs index 8ff7e9b..e538365 100644 --- a/src/Z21.Client/Core/IZ21CommandStation.cs +++ b/src/Z21.Client/Core/IZ21CommandStation.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Tasks; using CommandStation; using Z21.Core.Command; @@ -19,5 +20,21 @@ public interface IZ21CommandStation : ICommandStation, ILocoControl, IAccessoryC /// Sends one or more raw commands in a single UDP packet. /// Task SendCommandsAsync(params IZ21Command[] commands); + + /// + /// Reads a CV of a locomotive decoder on the main track (POM), retrying while the decoder does not + /// acknowledge, and returns the value. Requires RailCom; without it the read times out. Throws + /// on timeout or on + /// a short circuit. + /// + Task ReadPomCvAsync(ushort locoAddress, ushort cvAddress, TimeSpan timeout); + + /// + /// Writes a CV of a locomotive decoder on the main track (POM). Because a POM write returns no + /// acknowledgement, this verifies by reading the CV back and retrying until the read-back matches + /// the written value (so it requires RailCom). Throws if + /// it cannot be confirmed within . + /// + Task WritePomCvAsync(ushort locoAddress, ushort cvAddress, byte value, TimeSpan timeout); } } diff --git a/src/Z21.Client/Core/Z21CommandStation.cs b/src/Z21.Client/Core/Z21CommandStation.cs index 4cc2b18..5d46359 100644 --- a/src/Z21.Client/Core/Z21CommandStation.cs +++ b/src/Z21.Client/Core/Z21CommandStation.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; using CommandStation; using CommandStation.Transport; @@ -31,6 +32,7 @@ public class Z21CommandStation : IZ21CommandStation, IProgrammingControl, IFeedb private readonly Z21ResponseHandler _dispatcher; private readonly Z21Options _options; private readonly DelayedAction _delayedKeepAliveAction; + private readonly SemaphoreSlim _cvLock = new(1, 1); private readonly ILogger? _logger; /// @@ -180,6 +182,159 @@ public Task SetExtAccessoryAsync(ushort accessoryAddress, byte payload) => public Task WriteCvAsync(ushort cvAddress, byte value) => SendCommandsAsync(Commands.Create(cvAddress, value)); + public async Task ReadCvAsync(ushort cvAddress, TimeSpan timeout) + { + using CancellationTokenSource deadline = new(timeout); + await AcquireCvLockAsync(cvAddress, timeout, deadline.Token); + try + { + return await AwaitResultLoopAsync(cvAddress, () => SendCommandsAsync(Commands.Create(cvAddress)), deadline.Token, timeout); + } + finally + { + _cvLock.Release(); + } + } + + public async Task WriteCvAsync(ushort cvAddress, byte value, TimeSpan timeout) + { + using CancellationTokenSource deadline = new(timeout); + await AcquireCvLockAsync(cvAddress, timeout, deadline.Token); + try + { + await AwaitResultLoopAsync(cvAddress, () => SendCommandsAsync(Commands.Create(cvAddress, value)), deadline.Token, timeout); + } + finally + { + _cvLock.Release(); + } + } + + public async Task ReadPomCvAsync(ushort locoAddress, ushort cvAddress, TimeSpan timeout) + { + using CancellationTokenSource deadline = new(timeout); + await AcquireCvLockAsync(cvAddress, timeout, deadline.Token); + try + { + return await AwaitResultLoopAsync(cvAddress, () => SendCommandsAsync(Commands.Create(locoAddress, cvAddress)), deadline.Token, timeout); + } + finally + { + _cvLock.Release(); + } + } + + public async Task WritePomCvAsync(ushort locoAddress, ushort cvAddress, byte value, TimeSpan timeout) + { + using CancellationTokenSource deadline = new(timeout); + await AcquireCvLockAsync(cvAddress, timeout, deadline.Token); + try + { + while (true) + { + await SendCommandsAsync(Commands.Create(locoAddress, cvAddress, value)); + byte readBack = await AwaitResultLoopAsync(cvAddress, () => SendCommandsAsync(Commands.Create(locoAddress, cvAddress)), deadline.Token, timeout); + if (readBack == value) + return; + if (deadline.IsCancellationRequested) + throw new CvOperationTimeoutException(cvAddress, timeout); + } + } + finally + { + _cvLock.Release(); + } + } + + private async Task AcquireCvLockAsync(ushort cvAddress, TimeSpan timeout, CancellationToken deadline) + { + try + { + await _cvLock.WaitAsync(deadline); + } + catch (OperationCanceledException) + { + throw new CvOperationTimeoutException(cvAddress, timeout); + } + } + + private async Task AwaitResultLoopAsync(ushort cvAddress, Func send, CancellationToken deadline, TimeSpan timeout) + { + while (true) + { + if (deadline.IsCancellationRequested) + throw new CvOperationTimeoutException(cvAddress, timeout); + + CvAttempt attempt; + try + { + attempt = await AwaitNextCvAsync(cvAddress, send, deadline); + } + catch (OperationCanceledException) + { + throw new CvOperationTimeoutException(cvAddress, timeout); + } + + switch (attempt.Kind) + { + case CvAttemptKind.Result: + return attempt.Value; + case CvAttemptKind.ShortCircuit: + throw new CvShortCircuitException(cvAddress); + default: + break; // missing acknowledgement -> retry until the deadline + } + } + } + + private async Task AwaitNextCvAsync(ushort cvAddress, Func send, CancellationToken deadline) + { + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnResult(object? sender, CvValue value) + { + if (value.CvAddress == cvAddress) + completion.TrySetResult(new CvAttempt(CvAttemptKind.Result, value.Value)); + } + + void OnFailed(object? sender, CvProgrammingError error) => + completion.TrySetResult(new CvAttempt(error == CvProgrammingError.ShortCircuit ? CvAttemptKind.ShortCircuit : CvAttemptKind.Nack, 0)); + + CvReadCompleted += OnResult; + CvProgrammingFailed += OnFailed; + try + { + await send(); + using (deadline.Register(() => completion.TrySetCanceled(deadline))) + return await completion.Task; + } + finally + { + CvReadCompleted -= OnResult; + CvProgrammingFailed -= OnFailed; + } + } + + private enum CvAttemptKind + { + Result, + Nack, + ShortCircuit + } + + private readonly struct CvAttempt + { + public CvAttempt(CvAttemptKind kind, byte value) + { + Kind = kind; + Value = value; + } + + public CvAttemptKind Kind { get; } + + public byte Value { get; } + } + public Task RequestFeedbackAsync(byte groupIndex) => SendCommandsAsync(Commands.Create(groupIndex)); public Task RequestModelTimeAsync() => SendCommandsAsync(Commands.Create(FastClockAction.Read)); @@ -208,6 +363,7 @@ private async Task KeepAliveAsync() public void Dispose() { _delayedKeepAliveAction.Dispose(); + _cvLock.Dispose(); GC.SuppressFinalize(this); } }