Take the rest of what N1MM's QTC window does on CW

Reading the source again after this was tried turned up several things it had
wrong or left out.

The TU message went out only when reading a series out. N1MM sends it whichever
way the traffic went, and lets it name the series: {QTC} in it stands for the
header, so TU {QTC} 73 goes out as TU QTC 3/10 73.

The RX Ready button was hidden while sending and sent nothing on CW. It is
N1MM's QRV button: reading out it says R U QRV and keys QRV?, taking down it
says QRV and keys QRV.

The fields were asked for again with shift and Enter, which was made up. N1MM
uses the number keys, and writes them under the lines: 1, 2, 3 and 4 send the
time, call, serial or header of the last line again while reading out, and
shift with 1, 2 or 3 asks for that field while taking down.

The buttons now carry N1MM's names for the direction the traffic is going —
R U QRV, Snd Hdr, Snd1 to Snd10, Exit reading out; QRV, Hdr Agn, Agn1 to Agn10,
Exit taking down — and the Cfm column and Clear are hidden while reading out,
as N1MM hides them. The separate Send Hdr button is gone: N1MM has one button
there that sends the header one way and asks for it the other.

The log window gained N1MM's QTC? column for a contest that carries QTC
traffic. It holds the series, so a QTC row can be told from a contact; before
this a QTC row read as an ordinary one with odd values in the report columns.

Driven under Xvfb against a fake cwdaemon. Reading out: QRV?, QTC 3/2,
2319 DL7GGG 03, then 2319 and QTC 3/2 from the number keys, and
TU QTC 3/2 73 on Exit. Taking down: QRV, AGN TIME, AGN CALL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-30 23:23:22 +00:00
parent dc1c593b4d
commit 9424c488ea
9 changed files with 181 additions and 56 deletions

View File

@@ -559,25 +559,44 @@ they ask for again, and the CW messages below. The defaults are N1MM's — both
steps skipped, the header cleared, the line left alone. steps skipped, the header cleared, the line left alone.
On CW the window puts the traffic on the air itself, through the same keyer a On CW the window puts the traffic on the air itself, through the same keyer a
function key sends through: function key sends through. The buttons carry N1MM's names, which say what they
do in the direction the traffic is going.
Reading a series out:
| | | | | |
|---|---| |---|---|
| Send Hdr | the header of the series, `QTC 3/10` | | R U QRV | `QRV?`, asking whether the other station is ready |
| Send1 to Send10 | one line: the time, the callsign and the serial number | | Snd Hdr | the header of the series, `QTC 3/10` |
| Close | the TU message, when one is set | | Snd1 to Snd10 | one line: the time, the callsign and the serial number |
| Hdr Agn | `HDR AGN`, asking for the header again | | 1, 2, 3, 4 | sends the time, the call, the serial or the header of the last line again, for a station that missed a piece of it |
| Shift and Enter in a box | asks for that one field again — `AGN TIME`, `AGN CALL`, `AGN SERIAL` — and clears it | | Exit | saves the traffic and sends the TU message |
The gap between the fields of a line is written N1MM's way, with `S` for a Taking one down:
space, so `SS` is two and anything else goes out as it stands. All five messages
are N1MM's defaults and are edited in Setup. The serial number goes out as | | |
digits: N1MM can send cut numbers, and nothing here does, for any message. |---|---|
| QRV | `QRV`, telling the other station to go ahead |
| Hdr Agn | `HDR AGN`, asking for the header again |
| Shift 1, Shift 2, Shift 3 | asks for that one field again — `AGN TIME`, `AGN CALL`, `AGN SERIAL` — and clears it |
| Exit | saves the traffic and sends the TU message |
The TU message goes out whichever way the traffic went, and `{QTC}` in it stands
for the header, so `TU {QTC} 73` goes out as `TU QTC 3/10 73`. It is empty out
of the box, and then nothing is sent. The gap between the fields of a line is
written N1MM's way, with `S` for a space, so `SS` is two and anything else goes
out as it stands. All of it is edited in Setup, with N1MM's defaults. The serial
number goes out as digits: N1MM can send cut numbers, and nothing here does, for
any message.
Nothing is sent on SSB or RTTY. N1MM plays four recordings on SSB — QRV, Agn, Nothing is sent on SSB or RTTY. N1MM plays four recordings on SSB — QRV, Agn,
Cfm and TU — and sends RTTY through its digital window; this program has no Cfm and TU — and sends RTTY through its digital window; this program has no
voice keyer and no digital window, so on those two modes Agn, Cfm and RX Ready voice keyer and no digital window, so on those two modes the buttons move the
move the cursor as they did before. cursor as they did before. N1MM's RTTY window also has a Send All button that
keys the whole series at once; there is nothing to send it with here.
In a WAE contest the log window has N1MM's **QTC?** column, which holds the
series a QTC row belongs to and is empty for an ordinary contact.
The scoring was checked against six real WAE logs, and agrees with N1MM on The scoring was checked against six real WAE logs, and agrees with N1MM on
points and on weighted multipliers to the contact. points and on weighted multipliers to the contact.

View File

@@ -39,12 +39,13 @@ and passed over. Acting on them means holding N1MM's section lists and its
rules about which sections are retired, and getting that wrong throws away good rules about which sections are retired, and getting that wrong throws away good
data. The file is read without them. data. The file is read without them.
**The QTC window transmits on CW only.** The header, the lines, the TU and the **The QTC window transmits on CW only.** The header, the lines, the QRV, the TU
four again messages go out through the keyer, with N1MM's messages and N1MM's and the again messages go out through the keyer, with N1MM's messages and
defaults. Nothing goes out on SSB or RTTY: N1MM plays four recordings on SSB — N1MM's defaults. Nothing goes out on SSB or RTTY: N1MM plays four recordings on
QRV, Agn, Cfm and TU — and sends RTTY from its digital window, and this program SSB — QRV, Agn, Cfm and TU — and sends RTTY from its digital window, and this
has neither a voice keyer nor a digital window. RX Ready therefore sends nothing program has neither a voice keyer nor a digital window. N1MM's Send All, which
on any mode here, because N1MM only ever sends a recording from it. keys a whole series at once, belongs to that RTTY window and is not here
either.
**Cut numbers are not sent.** N1MM can key a serial number as letters — `N` for **Cut numbers are not sent.** N1MM can key a serial number as letters — `N` for
9, `T` for 0 — and offers several styles. Nothing here does, in a QTC line or in 9, `T` for 0 — and offers several styles. Nothing here does, in a QTC line or in

View File

@@ -18,7 +18,6 @@
<TextBlock Text="QTC Header :" FontSize="11" Opacity="0.7" /> <TextBlock Text="QTC Header :" FontSize="11" Opacity="0.7" />
<StackPanel Orientation="Horizontal" Spacing="4"> <StackPanel Orientation="Horizontal" Spacing="4">
<TextBox Name="HeaderBox" Width="170" /> <TextBox Name="HeaderBox" Width="170" />
<Button Name="HeaderSendButton" Content="Send Hdr" Click="OnHeaderSend" />
<Button Name="ReadyButton" Content="RX Ready" Click="OnReady" /> <Button Name="ReadyButton" Content="RX Ready" Click="OnReady" />
<Button Name="HeaderAgainButton" Content="Hdr Agn" Click="OnHeaderAgain" /> <Button Name="HeaderAgainButton" Content="Hdr Agn" Click="OnHeaderAgain" />
<Button Name="HeaderCfmButton" Content="Cfm" Click="OnHeaderConfirm" /> <Button Name="HeaderCfmButton" Content="Cfm" Click="OnHeaderConfirm" />
@@ -29,7 +28,7 @@
Text="Enter, Tab move forward — space moves within a QTC line. Green = saved, red = not filled, yellow = format error." /> Text="Enter, Tab move forward — space moves within a QTC line. Green = saved, red = not filled, yellow = format error." />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,6,0,0"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,6,0,0">
<TextBlock Name="StatusText" VerticalAlignment="Center" FontSize="11" Opacity="0.7" /> <TextBlock Name="StatusText" VerticalAlignment="Center" FontSize="11" Opacity="0.7" />
<Button Content="Clear" Click="OnClear" /> <Button Name="ClearButton" Content="Clear" Click="OnClear" />
<Button Name="CloseButton" Content="Close" Click="OnClose" IsDefault="True" /> <Button Name="CloseButton" Content="Close" Click="OnClose" IsDefault="True" />
<Button Content="Cancel" Click="OnCancel" /> <Button Content="Cancel" Click="OnCancel" />
</StackPanel> </StackPanel>

View File

@@ -35,6 +35,10 @@ public sealed partial class QtcWindow : Window
private readonly Callsign station; private readonly Callsign station;
private readonly Func<string, Task> send; private readonly Func<string, Task> send;
private bool isSending; private bool isSending;
/// The line last read out, which is the one the number keys repeat a field
/// of.
private int lastSent;
private readonly List<Row> rows = []; private readonly List<Row> rows = [];
private readonly List<WaeQtc> ready = []; private readonly List<WaeQtc> ready = [];
@@ -72,6 +76,21 @@ public sealed partial class QtcWindow : Window
: session.Settings.QtcSkipReady ? HeaderBox : session.Settings.QtcSkipReady ? HeaderBox
: ReadyButton; : ReadyButton;
/// N1MM writes the number keys under the lines. They only do anything on
/// CW, so the line says something else on the other modes.
private string KeysLine()
{
if (!IsCw)
{
return isSending
? $"{traffic.Remaining(station)} of the ten QTCs for {station.Text} are still free"
: "Type the header, then a line per QTC: time, callsign, serial number.";
}
return isSending
? "1 = time 2 = call 3 = serial 4 = header — sends that field of the line again"
: "Shift 1 = ask time Shift 2 = ask call Shift 3 = ask serial";
}
/// Traffic goes out on CW alone. The contest says which mode it is, not /// Traffic goes out on CW alone. The contest says which mode it is, not
/// what the radio happens to be on, which is how N1MM decides too. /// what the radio happens to be on, which is how N1MM decides too.
private bool IsCw => position.Contest.Modes is [ModeCategory.Cw]; private bool IsCw => position.Contest.Modes is [ModeCategory.Cw];
@@ -91,13 +110,16 @@ public sealed partial class QtcWindow : Window
private void Reset() private void Reset()
{ {
Title = isSending ? $"Send QTC · {station.Text}" : $"Receive QTC · {station.Text}"; Title = isSending ? $"Send QTC · {station.Text}" : $"Receive QTC · {station.Text}";
KeysText.Text = isSending KeysText.Text = KeysLine();
? $"{traffic.Remaining(station)} of the ten QTCs for {station.Text} are still free" // N1MM's labels: the buttons say what they do in the direction the
: "Type the header, then a line per QTC: time, callsign, serial number."; // traffic is going, and the ones with nothing to do are hidden
ReadyButton.IsVisible = !isSending; ReadyButton.IsVisible = IsCw || !isSending;
HeaderAgainButton.IsVisible = !isSending; ReadyButton.Content = !IsCw ? "RX Ready" : isSending ? "R U QRV" : "QRV";
HeaderAgainButton.Content = isSending && IsCw ? "Snd Hdr" : "Hdr Agn";
HeaderAgainButton.IsVisible = !isSending || IsCw;
HeaderCfmButton.IsVisible = !isSending; HeaderCfmButton.IsVisible = !isSending;
HeaderSendButton.IsVisible = isSending && IsCw; ClearButton.IsVisible = !isSending;
CloseButton.Content = IsCw ? "Exit" : "Close";
HeaderBox.IsReadOnly = isSending; HeaderBox.IsReadOnly = isSending;
BuildRows(); BuildRows();
Fill(); Fill();
@@ -122,15 +144,16 @@ public sealed partial class QtcWindow : Window
TextBox number = Box(at, 2); TextBox number = Box(at, 2);
Button again = new() Button again = new()
{ {
Content = isSending && IsCw ? $"Send{at + 1}" : $"Agn{at + 1}", Content = isSending && IsCw ? $"Snd{at + 1}" : $"Agn{at + 1}",
Margin = new Avalonia.Thickness(2, 1, 2, 1), Margin = new Avalonia.Thickness(2, 1, 2, 1),
}; };
Button confirm = new() { Content = $"Cfm{at + 1}", Margin = new Avalonia.Thickness(2, 1, 2, 1) }; Button confirm = new() { Content = $"Cfm{at + 1}", Margin = new Avalonia.Thickness(2, 1, 2, 1) };
int line = at; int line = at;
again.Click += (_, _) => OnLineAgain(line); again.Click += (_, _) => OnLineAgain(line);
confirm.Click += (_, _) => OnLineConfirm(line); confirm.Click += (_, _) => OnLineConfirm(line);
// nothing to confirm when a RTTY station is reading its own log out // nothing to confirm while reading our own log out, which is how
confirm.IsVisible = !(isSending && position.Contest.Modes.Contains(ModeCategory.Digital)); // N1MM hides the column
confirm.IsVisible = !isSending;
Grid.SetRow(again, at); Grid.SetRow(again, at);
Grid.SetColumn(again, 3); Grid.SetColumn(again, 3);
Grid.SetRow(confirm, at); Grid.SetRow(confirm, at);
@@ -238,13 +261,15 @@ public sealed partial class QtcWindow : Window
e.Handled = true; e.Handled = true;
Close(false); Close(false);
break; break;
case Key.Enter when e.Source is TextBox box case >= Key.D1 and <= Key.D4 when IsCw && isSending:
&& !isSending case >= Key.NumPad1 and <= Key.NumPad4 when IsCw && isSending:
&& IsCw
&& e.KeyModifiers.HasFlag(KeyModifiers.Shift)
&& box != HeaderBox:
e.Handled = true; e.Handled = true;
SendFieldAgain(box); SendField(FieldOf(e.Key));
break;
case >= Key.D1 and <= Key.D3 when IsCw && e.KeyModifiers.HasFlag(KeyModifiers.Shift):
case >= Key.NumPad1 and <= Key.NumPad3 when IsCw && e.KeyModifiers.HasFlag(KeyModifiers.Shift):
e.Handled = true;
AskFieldAgain(FieldOf(e.Key));
break; break;
case Key.Enter when e.Source is TextBox: case Key.Enter when e.Source is TextBox:
e.Handled = true; e.Handled = true;
@@ -286,20 +311,29 @@ public sealed partial class QtcWindow : Window
CloseButton.Focus(); CloseButton.Focus();
} }
/// N1MM's RX Ready tells the other station to go ahead, and does it with a /// Reading traffic out, this asks the other station whether it is ready;
/// recording. There is no voice keyer here, so it only puts the cursor /// taking it down, it says we are. N1MM keys `QRV?` and `QRV` and plays a
/// where the first line will land. /// recording on SSB, which there is no keyer for here.
private void OnReady(object? sender, RoutedEventArgs e) => rows[0].Time.Focus(); private void OnReady(object? sender, RoutedEventArgs e)
/// The header of the series we are about to read out: `QTC 3/10`.
private void OnHeaderSend(object? sender, RoutedEventArgs e)
{ {
_ = send(HeaderBox.Text ?? ""); if (IsCw)
rows[0].Again.Focus(); {
_ = send(isSending ? QtcMessages.AreYouReady : QtcMessages.ReadyToReceive);
}
Control next = isSending ? rows[0].Again : rows[0].Time;
next.Focus();
} }
/// One button, as N1MM has it: reading traffic out it keys the header of
/// the series, taking it down it asks for the header again.
private void OnHeaderAgain(object? sender, RoutedEventArgs e) private void OnHeaderAgain(object? sender, RoutedEventArgs e)
{ {
if (isSending)
{
_ = send(HeaderBox.Text ?? "");
rows[0].Again.Focus();
return;
}
if (IsCw) if (IsCw)
{ {
_ = send(QtcMessages.HeaderAgain); _ = send(QtcMessages.HeaderAgain);
@@ -340,6 +374,7 @@ public sealed partial class QtcWindow : Window
/// line has gone the cursor lands on Close, which sends the TU message. /// line has gone the cursor lands on Close, which sends the TU message.
private void SendLine(int at) private void SendLine(int at)
{ {
lastSent = at;
_ = send(QtcMessages.Line( _ = send(QtcMessages.Line(
rows[at].Time.Text ?? "", rows[at].Time.Text ?? "",
rows[at].Call.Text ?? "", rows[at].Call.Text ?? "",
@@ -356,24 +391,58 @@ public sealed partial class QtcWindow : Window
CloseButton.Focus(); CloseButton.Focus();
} }
/// N1MM's Shift+Enter: ask for one field of the line being taken down /// 1, 2, 3 and 4 as N1MM numbers them: the time, the call, the serial
/// again, and clear it. One message per column, all three in the settings. /// number and the header.
private void SendFieldAgain(TextBox box) private static int FieldOf(Key key) =>
key is >= Key.NumPad1 and <= Key.NumPad4 ? key - Key.NumPad1 : key - Key.D1;
/// Reading traffic out, the number keys send one field of the line last
/// sent again, for a station that missed a piece of it.
private void SendField(int field)
{ {
int column = rows.Select(r => r.Time).Contains(box) ? 0 if (field == 3)
: rows.Select(r => r.Call).Contains(box) ? 1 {
: 2; _ = send(HeaderBox.Text ?? "");
_ = send(column switch return;
}
Row row = rows[Math.Max(0, lastSent)];
_ = send((field switch
{
0 => row.Time.Text,
1 => row.Call.Text,
_ => row.Number.Text,
} ?? "").Trim());
}
/// Taking traffic down, shift and a number ask for that field again and
/// clear it. One message per field, all three in the settings.
private void AskFieldAgain(int field)
{
_ = send(field switch
{ {
0 => session.Settings.QtcCwTimeAgain, 0 => session.Settings.QtcCwTimeAgain,
1 => session.Settings.QtcCwCallAgain, 1 => session.Settings.QtcCwCallAgain,
_ => session.Settings.QtcCwNumberAgain, _ => session.Settings.QtcCwNumberAgain,
}); });
box.Text = ""; if (FocusedRow() is { } row)
box.Focus(); {
Paint(); TextBox box = field switch
{
0 => row.Time,
1 => row.Call,
_ => row.Number,
};
box.Text = "";
box.Focus();
Paint();
}
} }
/// The line the cursor is in, or the first one when it is somewhere else.
private Row? FocusedRow() =>
rows.FirstOrDefault(r => r.Time.IsFocused || r.Call.IsFocused || r.Number.IsFocused)
?? rows.FirstOrDefault();
private void OnLineConfirm(int at) private void OnLineConfirm(int at)
{ {
Control next = at + 1 < rows.Count ? rows[at + 1].Time : CloseButton; Control next = at + 1 < rows.Count ? rows[at + 1].Time : CloseButton;
@@ -408,9 +477,9 @@ public sealed partial class QtcWindow : Window
} }
} }
Save(lines); Save(lines);
if (isSending && IsCw && session.Settings.QtcCwTu.Trim().Length > 0) if (IsCw && session.Settings.QtcCwTu.Trim().Length > 0)
{ {
_ = send(session.Settings.QtcCwTu); _ = send(QtcMessages.Tu(session.Settings.QtcCwTu, HeaderBox.Text ?? ""));
} }
Close(true); Close(true);
} }

View File

@@ -46,6 +46,10 @@ public interface Contest
/// contests the same question. /// contests the same question.
bool UsesItuZones => false; bool UsesItuZones => false;
/// True for a contest whose log carries QTC traffic as rows of its own.
/// The log window then shows the series they belong to.
bool HasQtcTraffic => false;
string SentExchangeFor(StationInfo me); string SentExchangeFor(StationInfo me);
int PointsFor(QsoContext qso); int PointsFor(QsoContext qso);

View File

@@ -61,6 +61,8 @@ public sealed class Wae : Contest
public IReadOnlyList<ModeCategory> Modes => [mode]; public IReadOnlyList<ModeCategory> Modes => [mode];
public bool HasQtcTraffic => true;
public string SentExchangeFor(StationInfo me) => "001"; public string SentExchangeFor(StationInfo me) => "001";
/// A QTC line counts, and so does every contact on a contest band. The /// A QTC line counts, and so does every contact on a contest band. The

View File

@@ -243,6 +243,12 @@ public sealed class QsoEditor
kinds[mapped] = field.Kind; kinds[mapped] = field.Kind;
columns.Add(new QsoColumn(field.Label, mapped, Math.Max(field.Width, field.Label.Length + 1))); columns.Add(new QsoColumn(field.Label, mapped, Math.Max(field.Width, field.Label.Length + 1)));
} }
if (contest.HasQtcTraffic)
{
// N1MM's header for the misc column in WAE, where it holds the
// series a QTC row belongs to
columns.Add(new QsoColumn("QTC?", QsoField.MiscText, 6));
}
columns.Add(new QsoColumn("Op", QsoField.Operator, 8)); columns.Add(new QsoColumn("Op", QsoField.Operator, 8));
return columns; return columns;
} }

View File

@@ -22,6 +22,12 @@ public static class QtcMessages
/// rather than into its settings, two spaces and all. /// rather than into its settings, two spaces and all.
public const string HeaderAgain = "HDR AGN"; public const string HeaderAgain = "HDR AGN";
/// What the station taking traffic down sends to say it is ready.
public const string ReadyToReceive = "QRV";
/// What the station reading traffic out asks before it starts.
public const string AreYouReady = "QRV?";
public static string Spacing(string setting) => public static string Spacing(string setting) =>
setting.Replace("S", " ", StringComparison.Ordinal); setting.Replace("S", " ", StringComparison.Ordinal);
@@ -33,4 +39,10 @@ public static class QtcMessages
string gap = Spacing(spacing); string gap = Spacing(spacing);
return $"{time.Trim()}{gap}{call.Trim()}{gap}{number.Trim()}"; return $"{time.Trim()}{gap}{call.Trim()}{gap}{number.Trim()}";
} }
/// The message that ends the exchange, sent whichever way the traffic went.
/// `{QTC}` in it stands for the header of the series, so `TU {QTC} 73`
/// goes out as `TU QTC 3/10 73`.
public static string Tu(string template, string header) =>
template.Replace("{QTC}", header.Trim().ToUpperInvariant(), StringComparison.OrdinalIgnoreCase);
} }

View File

@@ -24,6 +24,19 @@ public class QtcMessagesTests
Assert.Equal("1234/DL1ABC/123", QtcMessages.Line("1234", "DL1ABC", "123", "/")); Assert.Equal("1234/DL1ABC/123", QtcMessages.Line("1234", "DL1ABC", "123", "/"));
} }
/// N1MM lets the TU message name the series it is closing.
[Fact]
public void TheTuMessageCanCarryTheHeader()
{
Assert.Equal("TU QTC 3/10 73", QtcMessages.Tu("TU {QTC} 73", "QTC 3/10"));
}
[Fact]
public void ATuMessageWithoutTheHeaderIsLeftAlone()
{
Assert.Equal("TU 73", QtcMessages.Tu("TU 73", "QTC 3/10"));
}
[Fact] [Fact]
public void WhatIsInTheBoxesIsTrimmed() public void WhatIsInTheBoxesIsTrimmed()
{ {