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);
}
}