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
24 changes: 24 additions & 0 deletions src/CommandStation.Abstractions/CvOperationTimeoutException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;

namespace CommandStation
{
/// <summary>
/// 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).
/// </summary>
public sealed class CvOperationTimeoutException : Exception
{
/// <summary>The 0-based CV address the operation targeted (0 = CV1).</summary>
public ushort CvAddress { get; }

/// <summary>The timeout that elapsed before a result was received.</summary>
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;
}
}
}
20 changes: 20 additions & 0 deletions src/CommandStation.Abstractions/CvShortCircuitException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;

namespace CommandStation
{
/// <summary>
/// Thrown when a CV programming operation is aborted because the command station reported a short
/// circuit on the track. The operation is not retried.
/// </summary>
public sealed class CvShortCircuitException : Exception
{
/// <summary>The 0-based CV address the operation targeted (0 = CV1).</summary>
public ushort CvAddress { get; }

public CvShortCircuitException(ushort cvAddress)
: base($"CV programming aborted: short circuit on the track (CV {cvAddress + 1}).")
{
CvAddress = cvAddress;
}
}
}
15 changes: 15 additions & 0 deletions src/CommandStation.Abstractions/IProgrammingControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ public interface IProgrammingControl

Task WriteCvAsync(ushort cvAddress, byte value);

/// <summary>
/// Reads a CV on the programming track, retrying while the decoder does not acknowledge, and
/// returns the value. Throws <see cref="CvOperationTimeoutException"/> if no result arrives within
/// <paramref name="timeout"/>, or <see cref="CvShortCircuitException"/> on a short circuit.
/// </summary>
Task<byte> ReadCvAsync(ushort cvAddress, TimeSpan timeout);

/// <summary>
/// Writes a CV on the programming track, retrying while the decoder does not acknowledge, and
/// completes once the command station confirms the write. Throws
/// <see cref="CvOperationTimeoutException"/> if not confirmed within <paramref name="timeout"/>, or
/// <see cref="CvShortCircuitException"/> on a short circuit.
/// </summary>
Task WriteCvAsync(ushort cvAddress, byte value, TimeSpan timeout);

event EventHandler<CvValue>? CvReadCompleted;

event EventHandler<CvProgrammingError>? CvProgrammingFailed;
Expand Down
215 changes: 215 additions & 0 deletions src/Z21.Client.UnitTest/Core/SafeCvProgrammingTest.cs
Original file line number Diff line number Diff line change
@@ -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<ICvResultResponseHandler> _cvResult = null!;
private Mock<ICvNackResponseHandler> _cvNack = null!;
private Mock<ICvNackShortCircuitResponseHandler> _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<ICvResultResponseHandler>();
_cvNack = new Mock<ICvNackResponseHandler>();
_cvNackSc = new Mock<ICvNackShortCircuitResponseHandler>();
Z21ResponseHandler dispatcher = new(_transport, new Z21FrameReader(), new List<IZ21ResponseHandler>());

_station = new Z21CommandStation(
_transport,
dispatcher,
factory,
new Z21Options(),
Mock.Of<ILocoInfoResponseHandler>(),
Mock.Of<ITurnoutInfoResponseHandler>(),
Mock.Of<IExtAccessoryInfoResponseHandler>(),
Mock.Of<ISystemStateDataChangedResponseHandler>(),
Mock.Of<IFirmwareVersionResponseHandler>(),
Mock.Of<IStatusChangedResponseHandler>(),
Mock.Of<ITrackPowerOnResponseHandler>(),
Mock.Of<ITrackPowerOffResponseHandler>(),
_cvResult.Object,
_cvNack.Object,
_cvNackSc.Object,
Mock.Of<IRmBusDataChangedResponseHandler>(),
Mock.Of<IFastClockDataResponseHandler>());

_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<byte> 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<byte> 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<byte> task = _station.ReadCvAsync(5, TimeSpan.FromSeconds(2));
RaiseShortCircuit();

Assert.ThrowsAsync<CvShortCircuitException>(async () => await task);
Assert.That(_transport.Sent, Has.Count.EqualTo(1));
}

[Test]
public void ReadCvAsync_NoResponseThrowsTimeout()
{
Task<byte> task = _station.ReadCvAsync(5, TimeSpan.FromMilliseconds(100));

Assert.ThrowsAsync<CvOperationTimeoutException>(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<CvOperationTimeoutException>(async () => await task);
}

[Test]
public async Task ReadPomCvAsync_ResultReturnsValue()
{
Task<byte> task = _station.ReadPomCvAsync(3, 5, TimeSpan.FromSeconds(2));
RaiseResult(5, 17);

Assert.That(await task, Is.EqualTo(17));
}

[Test]
public void ReadPomCvAsync_NoRailComReplyThrowsTimeout()
{
Task<byte> task = _station.ReadPomCvAsync(3, 5, TimeSpan.FromMilliseconds(100));

Assert.ThrowsAsync<CvOperationTimeoutException>(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<CvOperationTimeoutException>(async () => await task);
}
}
}
17 changes: 17 additions & 0 deletions src/Z21.Client/Core/IZ21CommandStation.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks;
using CommandStation;
using Z21.Core.Command;
Expand All @@ -19,5 +20,21 @@ public interface IZ21CommandStation : ICommandStation, ILocoControl, IAccessoryC
/// Sends one or more raw commands in a single UDP packet.
/// </summary>
Task SendCommandsAsync(params IZ21Command[] commands);

/// <summary>
/// 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
/// <see cref="CvOperationTimeoutException"/> on timeout or <see cref="CvShortCircuitException"/> on
/// a short circuit.
/// </summary>
Task<byte> ReadPomCvAsync(ushort locoAddress, ushort cvAddress, TimeSpan timeout);

/// <summary>
/// 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 <see cref="CvOperationTimeoutException"/> if
/// it cannot be confirmed within <paramref name="timeout"/>.
/// </summary>
Task WritePomCvAsync(ushort locoAddress, ushort cvAddress, byte value, TimeSpan timeout);
}
}
Loading
Loading