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
parent d084789ed2
commit ae48c04e71
17 changed files with 721 additions and 18 deletions

View File

@@ -24,6 +24,7 @@ public sealed class AppSession : IDisposable
private ClusterClient? cluster;
private StationNetwork? network;
private MessageSender? keyer;
private AlternatingCq? alternating;
private So2rBox? box;
public AppSession(UserPaths paths, Settings settings)
@@ -91,6 +92,10 @@ public sealed class AppSession : IDisposable
public MessageSender? Keyer => keyer;
/// Alternating CQ, or null while there is no keyer. It needs two radios to
/// do anything, and a keyer that reports when a message has gone out.
public AlternatingCq? Alternating => alternating;
/// The SO2R box, or null when there is none and the operator switches the
/// transmitter and the headphones by hand.
public So2rBox? Box => box;
@@ -245,6 +250,8 @@ public sealed class AppSession : IDisposable
/// without one.
public void ApplyKeyerSettings()
{
alternating?.Dispose();
alternating = null;
keyer?.Dispose();
keyer = null;
switch (Settings.KeyerKind.ToLowerInvariant())
@@ -261,6 +268,10 @@ public sealed class AppSession : IDisposable
if (keyer is not null)
{
_ = keyer.SetSpeedAsync(Settings.KeyerSpeed);
alternating = new AlternatingCq(
keyer,
CallCqOnAsync,
TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs));
}
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -303,12 +314,47 @@ public sealed class AppSession : IDisposable
{
return;
}
activeRadio = (activeRadio + 1) % positions.Count;
MoveToRadio(positions[(activeRadio + 1) % positions.Count].RadioNumber);
}
/// Moves the operator to a radio by number. Alternating CQ calls this from
/// the keyer's thread, so everything it raises is posted by the windows.
public void MoveToRadio(int radioNumber)
{
int at = positions.FindIndex(p => p.RadioNumber == radioNumber);
if (at < 0 || at == activeRadio)
{
return;
}
activeRadio = at;
_ = FollowActiveRadioAsync();
Changed?.Invoke(this, EventArgs.Empty);
ActiveRadioChanged?.Invoke(this, ActiveRadioNumber);
}
/// Moves to the radio and sends its CQ message, which is F1. Used by
/// alternating CQ; the operator's own F1 goes through the entry window.
private async Task CallCqOnAsync(int radioNumber)
{
RadioPosition position = positions.FirstOrDefault(p => p.RadioNumber == radioNumber)
?? throw new InvalidOperationException($"there is no radio {radioNumber}");
if (keyer is null)
{
throw new InvalidOperationException("there is no keyer");
}
string template = Messages.For(
position.Mode.Category,
Settings.CwMessages,
Settings.PhoneMessages)[0];
if (template.Length == 0)
{
throw new InvalidOperationException("F1 has no message — Config ▸ Keyer and messages");
}
MoveToRadio(radioNumber);
await PointTransmitAtAsync(radioNumber).ConfigureAwait(false);
await keyer.SendAsync(MessageExpander.Expand(template, position)).ConfigureAwait(false);
}
/// Puts both radios in the headphones, or goes back to one. An operator
/// listens to the second radio while the first is sending.
public void ToggleListenToBoth()
@@ -414,6 +460,7 @@ public sealed class AppSession : IDisposable
DisposeRadios();
cluster?.Dispose();
network?.Dispose();
alternating?.Dispose();
keyer?.Dispose();
box?.Dispose();
store?.Dispose();

View File

@@ -62,6 +62,11 @@ public sealed record Settings
public int KeyerSpeed { get; init; } = 28;
/// How long alternating CQ leaves between one message ending and the other
/// radio starting. N1MM asks for the same number and will not go below
/// 100 ms, because an SO2R box works relays.
public int AlternatingCqGapMs { get; init; } = 100;
/// Sub-band boundaries the operator has changed. Empty means the defaults
/// in `BandPlan.Default`; an entry replaces one band's boundaries.
public IReadOnlyList<StoredSubBand> SubBands { get; init; } = [];

View File

@@ -15,6 +15,13 @@
<TextBlock Grid.Column="4" Text="Speed" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="SpeedBox" Grid.Row="1" Grid.Column="4" />
</Grid>
<Grid ColumnDefinitions="150,8,*" RowDefinitions="Auto,Auto">
<TextBlock Text="Alternating CQ gap (ms)" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="AlternatingGapBox" Grid.Row="1" />
<TextBlock Grid.Column="2" Grid.Row="1" FontSize="11" Opacity="0.7"
VerticalAlignment="Center" TextWrapping="Wrap"
Text="Ctrl+B starts calling CQ on one radio and then the other. Needs two radios." />
</Grid>
<TextBlock Text="Messages" FontSize="11" Opacity="0.7" Margin="0,10,0,1" />
<StackPanel Orientation="Horizontal" Spacing="6">
<RadioButton Name="CwButton" Content="CW" GroupName="mode" IsChecked="True" />

View File

@@ -22,6 +22,7 @@ public sealed partial class KeyerDialog : Window
KindBox.SelectedItem = settings.KeyerKind;
KindBox.SelectionChanged += (_, _) => ShowTarget();
SpeedBox.Text = settings.KeyerSpeed.ToString();
AlternatingGapBox.Text = settings.AlternatingCqGapMs.ToString();
CwButton.IsCheckedChanged += (_, _) => ShowMessages();
ShowTarget();
BuildMessageBoxes();
@@ -91,6 +92,10 @@ public sealed partial class KeyerDialog : Window
? port
: settings.KeyerPort,
KeyerSpeed = int.TryParse(SpeedBox.Text, out int speed) ? speed : settings.KeyerSpeed,
// N1MM will not go below 100 ms either: the box works relays
AlternatingCqGapMs = int.TryParse(AlternatingGapBox.Text, out int gap)
? Math.Max(100, gap)
: settings.AlternatingCqGapMs,
CwMessages = cwMessages,
PhoneMessages = phoneMessages,
});

View File

@@ -234,6 +234,7 @@ public sealed partial class EntryWindow : Window
break;
case Key.Escape:
e.Handled = true;
session.Alternating?.Stop();
_ = session.Keyer?.AbortAsync();
Logging.Wipe();
SyncBoxes();
@@ -253,6 +254,10 @@ public sealed partial class EntryWindow : Window
e.Handled = true;
MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
break;
case Key.B when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
ToggleAlternatingCq();
break;
case Key.Y when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnEditLastContact(this, new RoutedEventArgs());
@@ -451,6 +456,46 @@ public sealed partial class EntryWindow : Window
}
}
/// Alternating CQ: CQ on this radio, then the other as each message ends.
/// N1MM calls it dueling CQs and puts it on the same key.
private void ToggleAlternatingCq()
{
if (session.Alternating is not { } alternating)
{
Status("no keyer — Config ▸ Keyer");
return;
}
if (alternating.IsRunning)
{
alternating.Stop();
Status("alternating CQ off");
return;
}
if (session.Positions.Count < 2)
{
Status("alternating CQ needs two radios");
return;
}
if (!alternating.IsPossible)
{
Status("this keyer does not report when a message has gone out");
return;
}
alternating.Stopped -= OnAlternatingStopped;
alternating.Stopped += OnAlternatingStopped;
alternating.Start(radioNumber);
Status($"alternating CQ · radio {radioNumber} first");
}
private void OnAlternatingStopped(object? sender, string reason)
{
if (sender is AlternatingCq alternating)
{
alternating.Stopped -= OnAlternatingStopped;
}
Dispatcher.UIThread.Post(() => Status($"alternating CQ stopped: {reason}"));
}
private void SendMessage(int index)
{
if (Logging is null || session.Keyer is null)