diff --git a/README.md b/README.md index af198f0..e2d26be 100644 --- a/README.md +++ b/README.md @@ -474,9 +474,15 @@ Not there yet: voice keying on the second radio. ### Alternating CQ **Ctrl+B** calls CQ on one radio, and when that message has gone out, moves to -the other radio and calls there, until Escape or another Ctrl+B. The keyboard, -the entry window and the SO2R box follow each turn. N1MM calls this dueling CQs -and puts it on the same key. +the other radio and calls there, until another Ctrl+B switches it off. The +keyboard, the entry window and the SO2R box follow each turn. N1MM calls this +dueling CQs and puts it on the same key. + +Only the CQ carries it on to the other radio. Sending anything else — the other +station's call, the exchange — holds the CQ where it is, and so does Escape, so +a station that answers can be worked without switching alternating CQ off. The +next CQ picks it up again, which is what the CQ after a logged contact does. +N1MM works the same way. It runs off the keyer saying the message has gone out, not off a guess at how long the text takes: `cwdaemon` answers the `h` reply request, and a diff --git a/docs/unfinished.md b/docs/unfinished.md index afa9e8a..b96186b 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -29,10 +29,6 @@ of an SO2R station cannot call CQ by voice. Alternating CQ therefore works on CW only, though nothing in it is CW-specific: a voice keyer that reports when the recording has finished would drive it as it stands. -**Alternating CQ does not restart itself after a contact.** Escape stops it, and -it has to be started again with Ctrl+B. N1MM carries on calling after the QSO is -logged. - **Call history: the section-validating directives.** `!!Validate50State!!`, `!!ValidateArrlSection!!`, `!!MapOnSection!!` and `!!GTA2GH_NT2TER!!` are read and passed over. Acting on them means holding N1MM's section lists and its diff --git a/src/Nonemm.App/Windows/EntryWindow.Macros.cs b/src/Nonemm.App/Windows/EntryWindow.Macros.cs index 4b32230..fd6a82d 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Macros.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Macros.cs @@ -84,6 +84,15 @@ public sealed partial class EntryWindow // the BARTG contest sends the time, and logs the one it sent Logging.StampSentTime(); } + // only a CQ carries alternating CQ on to the other radio + if (index == Esm.CallCq) + { + session.Alternating?.CarryOn(); + } + else + { + session.Alternating?.Hold(); + } string text = prefix + plan.Text; // the box has to point at this radio before the key does await session.PointTransmitAtAsync(radioNumber); diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index 68b1a53..d8b3e33 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -352,7 +352,9 @@ public sealed partial class EntryWindow : Window case Key.Escape: e.Handled = true; sending = false; - session.Alternating?.Stop(); + // N1MM's Escape holds alternating CQ where it is rather than + // switching it off; the next CQ carries it on + session.Alternating?.Hold(); _ = session.Keyer?.AbortAsync(); Logging.Wipe(); ResetEsm(); @@ -618,7 +620,7 @@ public sealed partial class EntryWindow : Window private void OnStopSending(object? sender, RoutedEventArgs e) { sending = false; - session.Alternating?.Stop(); + session.Alternating?.Hold(); _ = session.Keyer?.AbortAsync(); Status("stopped sending"); } diff --git a/src/Nonemm.Session/AlternatingCq.cs b/src/Nonemm.Session/AlternatingCq.cs index a0ac6d7..1c4c156 100644 --- a/src/Nonemm.Session/AlternatingCq.cs +++ b/src/Nonemm.Session/AlternatingCq.cs @@ -6,6 +6,13 @@ namespace Nonemm.Session; /// other radio and calls there. N1MM calls it dueling CQs and toggles it with /// Ctrl+B. /// +/// Only the CQ message carries the alternation on. A station answers, the +/// operator sends their call and the exchange, and those messages leave the +/// alternation where it is until the next CQ goes out. N1MM works the same +/// way: it holds on any other message and on Escape, and carries on once the +/// CQ message is sent again, which is what happens after the contact is +/// logged. +/// /// It runs off the keyer's completion signal. Working out when a message ends /// from the length of the text would be a guess, and a guess that runs short /// keys the second radio while the first is still sending. @@ -18,6 +25,7 @@ public sealed class AlternatingCq : IDisposable private readonly Func wait; private readonly Lock gate = new(); private CancellationTokenSource? running; + private bool carriesOn = true; /// `callCqOn` moves the operator to that radio and sends the CQ message. /// `wait` is there so a test does not have to sleep. @@ -52,6 +60,35 @@ public sealed class AlternatingCq : IDisposable /// The radio it is calling on now. public int RadioNumber { get; private set; } + /// True while a message finishing moves the CQ to the other radio, false + /// while the operator is working a station. + public bool CarriesOn + { + get + { + lock (gate) + { + return carriesOn; + } + } + } + + /// Says the CQ message has gone out, so the next message that finishes + /// moves the CQ to the other radio. + public void CarryOn() => Set(true); + + /// Says something else has gone out, or that the operator has stopped the + /// keyer, so the CQ stays where it is until the next one is sent. + public void Hold() => Set(false); + + private void Set(bool value) + { + lock (gate) + { + carriesOn = value; + } + } + /// Raised when it stops by itself, with the reason. Stopping by hand does /// not raise it. public event EventHandler? Stopped; @@ -74,6 +111,7 @@ public sealed class AlternatingCq : IDisposable token = running.Token; } RadioNumber = radioNumber; + Set(true); _ = CallAsync(radioNumber, token); } @@ -98,7 +136,7 @@ public sealed class AlternatingCq : IDisposable CancellationToken token; lock (gate) { - if (running is null) + if (running is null || !carriesOn) { return; } diff --git a/tests/Nonemm.Session.Tests/AlternatingCqTests.cs b/tests/Nonemm.Session.Tests/AlternatingCqTests.cs index 8a3155b..bfc2c16 100644 --- a/tests/Nonemm.Session.Tests/AlternatingCqTests.cs +++ b/tests/Nonemm.Session.Tests/AlternatingCqTests.cs @@ -104,6 +104,44 @@ public class AlternatingCqTests Assert.Equal([1, 2], called); } + /// A message that is not a CQ leaves the CQ where it is, and the next CQ + /// carries the alternation on. This is what happens while a contact is + /// worked, and it is N1MM's rule. + [Fact] + public void WorkingAStationHoldsTheCqOnOneRadio() + { + FakeKeyer keyer = new(); + List called = []; + using AlternatingCq cq = Driving(keyer, called); + + cq.Start(1); + cq.Hold(); + keyer.FinishMessage(); + keyer.FinishMessage(); + + Assert.Equal([1], called); + Assert.True(cq.IsRunning); + Assert.False(cq.CarriesOn); + } + + /// The CQ that follows a logged contact starts the alternation again, + /// without the operator switching it back on. + [Fact] + public void TheNextCqCarriesTheAlternationOnAgain() + { + FakeKeyer keyer = new(); + List called = []; + using AlternatingCq cq = Driving(keyer, called); + + cq.Start(1); + cq.Hold(); + keyer.FinishMessage(); + cq.CarryOn(); + keyer.FinishMessage(); + + Assert.Equal([1, 2], called); + } + [Fact] public void AKeyerThatCannotReportCompletionCannotDriveIt() {