diff --git a/README.md b/README.md
index 287071c..169e136 100644
--- a/README.md
+++ b/README.md
@@ -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.
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` |
-| Send1 to Send10 | one line: the time, the callsign and the serial number |
-| Close | the TU message, when one is set |
-| Hdr Agn | `HDR AGN`, asking for the header again |
-| Shift and Enter in a box | asks for that one field again — `AGN TIME`, `AGN CALL`, `AGN SERIAL` — and clears it |
+| R U QRV | `QRV?`, asking whether the other station is ready |
+| Snd Hdr | the header of the series, `QTC 3/10` |
+| Snd1 to Snd10 | one line: the time, the callsign and the serial number |
+| 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 |
+| 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
-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.
+Taking one down:
+
+| | |
+|---|---|
+| 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,
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
-move the cursor as they did before.
+voice keyer and no digital window, so on those two modes the buttons move the
+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
points and on weighted multipliers to the contact.
diff --git a/docs/unfinished.md b/docs/unfinished.md
index 0ba528c..0e4ed17 100644
--- a/docs/unfinished.md
+++ b/docs/unfinished.md
@@ -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
data. The file is read without them.
-**The QTC window transmits on CW only.** The header, the lines, the TU and the
-four again messages go out through the keyer, with N1MM's messages and N1MM's
-defaults. Nothing goes out on SSB or RTTY: N1MM plays four recordings on SSB —
-QRV, Agn, Cfm and TU — and sends RTTY from its digital window, and this program
-has neither a voice keyer nor a digital window. RX Ready therefore sends nothing
-on any mode here, because N1MM only ever sends a recording from it.
+**The QTC window transmits on CW only.** The header, the lines, the QRV, the TU
+and the again messages go out through the keyer, with N1MM's messages and
+N1MM's defaults. Nothing goes out on SSB or RTTY: N1MM plays four recordings on
+SSB — QRV, Agn, Cfm and TU — and sends RTTY from its digital window, and this
+program has neither a voice keyer nor a digital window. N1MM's Send All, which
+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
9, `T` for 0 — and offers several styles. Nothing here does, in a QTC line or in
diff --git a/src/Nonemm.App/Windows/QtcWindow.axaml b/src/Nonemm.App/Windows/QtcWindow.axaml
index 351ca2e..f8b58ee 100644
--- a/src/Nonemm.App/Windows/QtcWindow.axaml
+++ b/src/Nonemm.App/Windows/QtcWindow.axaml
@@ -18,7 +18,6 @@
-
@@ -29,7 +28,7 @@
Text="Enter, Tab move forward — space moves within a QTC line. Green = saved, red = not filled, yellow = format error." />
-
+
diff --git a/src/Nonemm.App/Windows/QtcWindow.axaml.cs b/src/Nonemm.App/Windows/QtcWindow.axaml.cs
index 587dede..e2acac6 100644
--- a/src/Nonemm.App/Windows/QtcWindow.axaml.cs
+++ b/src/Nonemm.App/Windows/QtcWindow.axaml.cs
@@ -35,6 +35,10 @@ public sealed partial class QtcWindow : Window
private readonly Callsign station;
private readonly Func send;
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 rows = [];
private readonly List ready = [];
@@ -72,6 +76,21 @@ public sealed partial class QtcWindow : Window
: session.Settings.QtcSkipReady ? HeaderBox
: 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
/// what the radio happens to be on, which is how N1MM decides too.
private bool IsCw => position.Contest.Modes is [ModeCategory.Cw];
@@ -91,13 +110,16 @@ public sealed partial class QtcWindow : Window
private void Reset()
{
Title = isSending ? $"Send QTC · {station.Text}" : $"Receive QTC · {station.Text}";
- KeysText.Text = 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.";
- ReadyButton.IsVisible = !isSending;
- HeaderAgainButton.IsVisible = !isSending;
+ KeysText.Text = KeysLine();
+ // N1MM's labels: the buttons say what they do in the direction the
+ // traffic is going, and the ones with nothing to do are hidden
+ ReadyButton.IsVisible = IsCw || !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;
- HeaderSendButton.IsVisible = isSending && IsCw;
+ ClearButton.IsVisible = !isSending;
+ CloseButton.Content = IsCw ? "Exit" : "Close";
HeaderBox.IsReadOnly = isSending;
BuildRows();
Fill();
@@ -122,15 +144,16 @@ public sealed partial class QtcWindow : Window
TextBox number = Box(at, 2);
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),
};
Button confirm = new() { Content = $"Cfm{at + 1}", Margin = new Avalonia.Thickness(2, 1, 2, 1) };
int line = at;
again.Click += (_, _) => OnLineAgain(line);
confirm.Click += (_, _) => OnLineConfirm(line);
- // nothing to confirm when a RTTY station is reading its own log out
- confirm.IsVisible = !(isSending && position.Contest.Modes.Contains(ModeCategory.Digital));
+ // nothing to confirm while reading our own log out, which is how
+ // N1MM hides the column
+ confirm.IsVisible = !isSending;
Grid.SetRow(again, at);
Grid.SetColumn(again, 3);
Grid.SetRow(confirm, at);
@@ -238,13 +261,15 @@ public sealed partial class QtcWindow : Window
e.Handled = true;
Close(false);
break;
- case Key.Enter when e.Source is TextBox box
- && !isSending
- && IsCw
- && e.KeyModifiers.HasFlag(KeyModifiers.Shift)
- && box != HeaderBox:
+ case >= Key.D1 and <= Key.D4 when IsCw && isSending:
+ case >= Key.NumPad1 and <= Key.NumPad4 when IsCw && isSending:
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;
case Key.Enter when e.Source is TextBox:
e.Handled = true;
@@ -286,20 +311,29 @@ public sealed partial class QtcWindow : Window
CloseButton.Focus();
}
- /// N1MM's RX Ready tells the other station to go ahead, and does it with a
- /// recording. There is no voice keyer here, so it only puts the cursor
- /// where the first line will land.
- private void OnReady(object? sender, RoutedEventArgs e) => rows[0].Time.Focus();
-
- /// The header of the series we are about to read out: `QTC 3/10`.
- private void OnHeaderSend(object? sender, RoutedEventArgs e)
+ /// Reading traffic out, this asks the other station whether it is ready;
+ /// taking it down, it says we are. N1MM keys `QRV?` and `QRV` and plays a
+ /// recording on SSB, which there is no keyer for here.
+ private void OnReady(object? sender, RoutedEventArgs e)
{
- _ = send(HeaderBox.Text ?? "");
- rows[0].Again.Focus();
+ if (IsCw)
+ {
+ _ = 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)
{
+ if (isSending)
+ {
+ _ = send(HeaderBox.Text ?? "");
+ rows[0].Again.Focus();
+ return;
+ }
if (IsCw)
{
_ = 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.
private void SendLine(int at)
{
+ lastSent = at;
_ = send(QtcMessages.Line(
rows[at].Time.Text ?? "",
rows[at].Call.Text ?? "",
@@ -356,24 +391,58 @@ public sealed partial class QtcWindow : Window
CloseButton.Focus();
}
- /// N1MM's Shift+Enter: ask for one field of the line being taken down
- /// again, and clear it. One message per column, all three in the settings.
- private void SendFieldAgain(TextBox box)
+ /// 1, 2, 3 and 4 as N1MM numbers them: the time, the call, the serial
+ /// number and the header.
+ 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
- : rows.Select(r => r.Call).Contains(box) ? 1
- : 2;
- _ = send(column switch
+ if (field == 3)
+ {
+ _ = send(HeaderBox.Text ?? "");
+ 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,
1 => session.Settings.QtcCwCallAgain,
_ => session.Settings.QtcCwNumberAgain,
});
- box.Text = "";
- box.Focus();
- Paint();
+ if (FocusedRow() is { } row)
+ {
+ 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)
{
Control next = at + 1 < rows.Count ? rows[at + 1].Time : CloseButton;
@@ -408,9 +477,9 @@ public sealed partial class QtcWindow : Window
}
}
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);
}
diff --git a/src/Nonemm.Contests/Contest.cs b/src/Nonemm.Contests/Contest.cs
index 5e5f754..2b7aa5c 100644
--- a/src/Nonemm.Contests/Contest.cs
+++ b/src/Nonemm.Contests/Contest.cs
@@ -46,6 +46,10 @@ public interface Contest
/// contests the same question.
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);
int PointsFor(QsoContext qso);
diff --git a/src/Nonemm.Contests/Rules/Wae.cs b/src/Nonemm.Contests/Rules/Wae.cs
index ce8f9b0..e964dc2 100644
--- a/src/Nonemm.Contests/Rules/Wae.cs
+++ b/src/Nonemm.Contests/Rules/Wae.cs
@@ -61,6 +61,8 @@ public sealed class Wae : Contest
public IReadOnlyList Modes => [mode];
+ public bool HasQtcTraffic => true;
+
public string SentExchangeFor(StationInfo me) => "001";
/// A QTC line counts, and so does every contact on a contest band. The
diff --git a/src/Nonemm.Session/QsoEditor.cs b/src/Nonemm.Session/QsoEditor.cs
index 715b9cd..4771607 100644
--- a/src/Nonemm.Session/QsoEditor.cs
+++ b/src/Nonemm.Session/QsoEditor.cs
@@ -243,6 +243,12 @@ public sealed class QsoEditor
kinds[mapped] = field.Kind;
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));
return columns;
}
diff --git a/src/Nonemm.Session/QtcMessages.cs b/src/Nonemm.Session/QtcMessages.cs
index 0f7cfe6..65fea4e 100644
--- a/src/Nonemm.Session/QtcMessages.cs
+++ b/src/Nonemm.Session/QtcMessages.cs
@@ -22,6 +22,12 @@ public static class QtcMessages
/// rather than into its settings, two spaces and all.
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) =>
setting.Replace("S", " ", StringComparison.Ordinal);
@@ -33,4 +39,10 @@ public static class QtcMessages
string gap = Spacing(spacing);
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);
}
diff --git a/tests/Nonemm.Session.Tests/QtcMessagesTests.cs b/tests/Nonemm.Session.Tests/QtcMessagesTests.cs
index ad6c059..decba6f 100644
--- a/tests/Nonemm.Session.Tests/QtcMessagesTests.cs
+++ b/tests/Nonemm.Session.Tests/QtcMessagesTests.cs
@@ -24,6 +24,19 @@ public class QtcMessagesTests
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]
public void WhatIsInTheBoxesIsTrimmed()
{