Alternate CQ between the two radios

Ctrl+B calls CQ on one radio, and when that message has gone out, moves to the
other and calls there. 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 turn is taken when the keyer says the message has ended, not when a timer
guesses it has. So MessageSender grew a Finished event and a ReportsCompletion
flag, and both keyers fill them in: cwdaemon answers the <ESC>h reply request
that now goes out in front of every message, and a WinKeyer clears the busy bit
in the status bytes it sends of its own accord. The status-byte reading is in
WinkeyerStatus, away from the serial port, because that is the half that can be
tested without a keyer on the desk. A keyer that reports nothing refuses to
start alternating CQ rather than keying the second radio over the first.

AlternatingCq itself takes the keyer, a callback that calls CQ on a radio, the
gap and a wait function, so the alternation is tested without sleeping. The gap
is in Config ▸ Keyer and messages and will not go below 100 ms, which is N1MM's
floor too: an SO2R box works relays.

docs/keying.md writes down why cwdaemon does the timing and we do not. N1MM
keys DTR itself with a coarse sleep, a busy-wait and a margin that grows every
time the sleep overshoots, and it raises the thread to TIME_CRITICAL for the
length of the message. The busy-wait ports to Linux; the priority does not,
without CAP_SYS_NICE, and a garbage collection mid-element is audible. A direct
serial keyer stays a reasonable third option, to be taken knowingly.

Running it against a fake daemon that takes 1.5 seconds to play a message: six
CQs went out back to back and the keyboard moved between the two entry windows
each time. Escape stopped it, let the message in flight finish, and started
nothing further.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 23:28:57 +00:00
co-authored by Claude Opus 5
parent d084789ed2
commit ae48c04e71
17 changed files with 721 additions and 18 deletions
+147
View File
@@ -0,0 +1,147 @@
using Nonemm.Keying;
namespace Nonemm.Session;
/// Calls CQ on one radio, and when that message has gone out, moves to the
/// other radio and calls there. N1MM calls it dueling CQs and toggles it with
/// Ctrl+B.
///
/// 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.
///
/// Two radios, because that is what an SO2R station has.
public sealed class AlternatingCq : IDisposable
{
private readonly MessageSender keyer;
private readonly Func<int, Task> callCqOn;
private readonly Func<TimeSpan, CancellationToken, Task> wait;
private readonly Lock gate = new();
private CancellationTokenSource? running;
/// `callCqOn` moves the operator to that radio and sends the CQ message.
/// `wait` is there so a test does not have to sleep.
public AlternatingCq(
MessageSender keyer,
Func<int, Task> callCqOn,
TimeSpan gap,
Func<TimeSpan, CancellationToken, Task>? wait = null)
{
this.keyer = keyer;
this.callCqOn = callCqOn;
Gap = gap;
this.wait = wait ?? Task.Delay;
keyer.Finished += OnFinished;
}
/// How long to leave between the message ending and the other radio
/// starting. The relays of an SO2R box need a moment.
public TimeSpan Gap { get; }
public bool IsRunning
{
get
{
lock (gate)
{
return running is not null;
}
}
}
/// The radio it is calling on now.
public int RadioNumber { get; private set; }
/// Raised when it stops by itself, with the reason. Stopping by hand does
/// not raise it.
public event EventHandler<string>? Stopped;
/// True when the keyer can drive this at all.
public bool IsPossible => keyer.ReportsCompletion;
public void Start(int radioNumber)
{
if (!IsPossible)
{
throw new InvalidOperationException("this keyer does not report when a message has gone out");
}
CancellationToken token;
lock (gate)
{
running?.Cancel();
running?.Dispose();
running = new CancellationTokenSource();
token = running.Token;
}
RadioNumber = radioNumber;
_ = CallAsync(radioNumber, token);
}
public void Stop()
{
lock (gate)
{
running?.Cancel();
running?.Dispose();
running = null;
}
}
public void Dispose()
{
keyer.Finished -= OnFinished;
Stop();
}
private void OnFinished(object? sender, EventArgs e)
{
CancellationToken token;
lock (gate)
{
if (running is null)
{
return;
}
token = running.Token;
}
_ = NextAsync(token);
}
private async Task NextAsync(CancellationToken token)
{
try
{
await wait(Gap, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
if (token.IsCancellationRequested)
{
return;
}
RadioNumber = RadioNumber == 1 ? 2 : 1;
await CallAsync(RadioNumber, token).ConfigureAwait(false);
}
/// A keyer that has gone away stops the whole thing rather than leaving one
/// radio calling into a dead port.
private async Task CallAsync(int radioNumber, CancellationToken token)
{
try
{
await callCqOn(radioNumber).ConfigureAwait(false);
}
catch (Exception e) when (e is InvalidOperationException or IOException)
{
// read before Stop, which cancels this same token
bool stoppedByHand = token.IsCancellationRequested;
Stop();
if (!stoppedByHand)
{
Stopped?.Invoke(this, e.Message);
}
}
}
}