Files
Nonemm/src/Nonemm.App/Windows/QtcWindow.axaml.cs
ericek111 9f635550bd Read a QTC series out on RTTY
N1MM's Send All: the whole series as one message — the heading, every
line with the operator's spacing between them, then the ending, with
`{TX}` and `{RX}` around it so it keys the transmitter and drops it
again. The shape is N1MM's, from `QTCWindow.cs:3609`. A line is the three
fields joined with hyphens, which is how N1MM writes them, and Snd n
sends one line again for a station that missed it.

One message rather than a button per line because a series is close to a
minute of transmission at 45 baud: the type-ahead buffer holds it and
paces the engine.

Three settings with N1MM's names and defaults — `WAESendAllHeadingText`,
`WAESendAllEndingText` and `WAESQTCSpacing` — on the QTC window's setup
dialog. `{ENTERLF}` is new to the expander and stands for a carriage
return and a line feed; `{QTC}` is filled in by the window, which is the
only place that knows which series is going out.

The messages are tested as text. Nothing has gone on the air.

Left out: N1MM's four SSB recordings, which need a voice keyer, and its
RTTY messages for the station taking traffic down, which are typed into
the digital window's transmit pane here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
2026-09-03 15:02:08 +00:00

614 lines
21 KiB
C#

using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Nonemm.App.Configuration;
using Nonemm.App.Dialogs;
using Nonemm.Contests.Rules;
using Nonemm.App.Theming;
using Nonemm.Core;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// Taking QTC traffic down, or reading it out. Laid out as N1MM lays it out: a
/// header, ten lines of time, callsign and serial number, an Agn and a Cfm
/// button beside each, and Clear, Close and Cancel underneath.
///
/// Receiving, the lines are typed as they arrive. Sending, they are filled from
/// the log and cannot be edited — what is being reported is what was worked.
///
/// On CW it puts the traffic on the air through the entry window's keyer, the
/// way N1MM's QTC window sends through its own entry window. On RTTY it sends
/// through the digital engine the same way, but a line at a time rather than a
/// field at a time, and Send All reads the whole series out in one message. On
/// SSB nothing is sent: that needs a voice keyer, and there is none here.
public sealed partial class QtcWindow : Window
{
private static IBrush Saved => Themes.Brush(Themes.Current.GoodBackground);
private static IBrush Unsaved => Themes.Brush(Themes.Current.EmptyBackground);
private static IBrush Malformed => Themes.Brush(Themes.Current.BadBackground);
private readonly AppSession session;
private readonly OperatingPosition position;
private readonly QtcTraffic traffic;
private readonly Callsign station;
private readonly Func<string, Task> 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<Row> rows = [];
private readonly List<WaeQtc> ready = [];
/// `send` puts a message on the air through the entry window this was
/// opened from, so QTC traffic goes out the same way a function key does.
public QtcWindow(
AppSession session,
OperatingPosition position,
Callsign station,
QtcDirection direction,
Func<string, Task> send)
{
this.session = session;
this.position = position;
this.station = station;
this.send = send;
isSending = direction == QtcDirection.Send;
traffic = new QtcTraffic(position);
InitializeComponent();
// on RTTY traffic goes both ways, so the operator says which this is
DirectionPanel.IsVisible = direction == QtcDirection.Either;
SendButton.IsChecked = isSending;
ReceiveButton.IsChecked = !isSending;
SendButton.IsCheckedChanged += (_, _) => SetDirection(SendButton.IsChecked == true);
Reset();
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
// focus set before the window is on screen does not stick
Opened += (_, _) => FirstBox().Focus();
}
/// Where the cursor goes: RX Ready when the operator wants that step, the
/// header when it has to be typed, the first line when it is filled in.
private Control FirstBox() =>
isSending ? rows[0].Time
: 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
? IsRtty
? "Send All reads the whole series out — Snd n sends one QTC again"
: $"{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";
}
/// Which mode the traffic goes out on. The contest says, not what the radio
/// happens to be on, which is how N1MM decides too.
private bool IsCw => position.Contest.Modes is [ModeCategory.Cw];
private bool IsRtty => position.Contest.Modes.Contains(ModeCategory.Digital);
/// The header of the series as it goes out in a message, which is N1MM's
/// `QTC 3/10`. The box shows the station callsign after it on RTTY, and
/// that is for the operator rather than for the air.
private string SeriesHeader => ready.Count > 0 ? $"QTC {ready[0].SeriesText}" : "";
private void SetDirection(bool sending)
{
if (sending == isSending)
{
return;
}
isSending = sending;
Reset();
FirstBox().Focus();
}
/// Everything that follows from which way the traffic goes.
private void Reset()
{
Title = isSending ? $"Send QTC · {station.Text}" : $"Receive QTC · {station.Text}";
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;
// N1MM's Send All, which reads a whole series out in one message. It
// needs the engine to hold the text, so it is RTTY only
SendAllButton.IsVisible = isSending && IsRtty;
ClearButton.IsVisible = !isSending;
CloseButton.Content = IsCw ? "Exit" : "Close";
HeaderBox.IsReadOnly = isSending;
BuildRows();
Fill();
}
private void BuildRows()
{
Lines.ColumnDefinitions.Clear();
Lines.RowDefinitions.Clear();
Lines.Children.Clear();
rows.Clear();
foreach (string width in new[] { "60", "120", "60", "Auto", "Auto" })
{
Lines.ColumnDefinitions.Add(new ColumnDefinition(
width == "Auto" ? GridLength.Auto : new GridLength(double.Parse(width))));
}
for (int at = 0; at < WaeQtc.MostPerSeries; at++)
{
Lines.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
TextBox time = Box(at, 0);
TextBox call = Box(at, 1);
TextBox number = Box(at, 2);
Button again = new()
{
Content = isSending && (IsCw || IsRtty) ? $"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 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);
Grid.SetColumn(confirm, 4);
Lines.Children.Add(again);
Lines.Children.Add(confirm);
rows.Add(new Row(time, call, number, again, confirm));
}
}
private TextBox Box(int row, int column)
{
TextBox box = new()
{
FontFamily = new FontFamily("monospace"),
Margin = new Avalonia.Thickness(2, 1, 2, 1),
IsReadOnly = isSending,
};
box.TextChanged += (_, _) => Paint();
Grid.SetRow(box, row);
Grid.SetColumn(box, column);
Lines.Children.Add(box);
return box;
}
/// Sending, the header and the lines come from the log. Receiving, both are
/// empty and the operator fills them in.
private void Fill()
{
ready.Clear();
if (!isSending)
{
HeaderBox.Text = "";
foreach (Row row in rows)
{
row.SetEnabled(true);
}
Paint();
return;
}
ready.AddRange(traffic.ToSend(station, session.Settings.QtcLinesPerSeries));
HeaderBox.Text = ready.Count == 0
? ""
: IsRtty
? $"{SeriesHeader} - {station.Text}"
: SeriesHeader;
for (int at = 0; at < rows.Count; at++)
{
bool has = at < ready.Count;
rows[at].Time.Text = has ? ready[at].TimeUtc : "";
rows[at].Call.Text = has ? ready[at].Call.Text : "";
rows[at].Number.Text = has ? $"{ready[at].Number:00}" : "";
rows[at].SetEnabled(has);
}
if (ready.Count == 0)
{
StatusText.Text = "nothing left to report";
}
Paint();
}
/// Green when the line is ready to save, yellow when it is filled in but
/// cannot be read, red while it is still empty.
private void Paint()
{
(int Series, int Count)? header = QtcTraffic.ReadHeader(HeaderBox.Text ?? "");
HeaderBox.Background = header is null
? (HeaderBox.Text ?? "").Trim().Length == 0 ? Unsaved : Malformed
: Saved;
int good = 0;
for (int at = 0; at < rows.Count; at++)
{
Row row = rows[at];
bool empty = row.IsEmpty;
WaeQtc? line = empty || header is null ? null : ReadLine(at, header.Value);
IBrush colour = line is not null ? Saved : empty ? Unsaved : Malformed;
row.Paint(colour);
if (line is not null)
{
good++;
}
}
StatusText.Text = $"{good} of {header?.Count ?? 0}";
}
private WaeQtc? ReadLine(int at, (int Series, int Count) header) =>
QtcTraffic.Read(
station,
isSending,
header.Series,
header.Count,
rows[at].Time.Text ?? "",
rows[at].Call.Text ?? "",
rows[at].Number.Text ?? "");
/// Enter and Tab move forward through the boxes; space moves within a line,
/// which is how an operator walks a QTC while listening.
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
{
switch (e.Key)
{
case Key.Escape:
e.Handled = true;
Close(false);
break;
case >= Key.D1 and <= Key.D4 when IsCw && isSending:
case >= Key.NumPad1 and <= Key.NumPad4 when IsCw && isSending:
e.Handled = true;
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;
MoveOn();
break;
case Key.Space when e.Source is TextBox:
e.Handled = true;
MoveOn();
break;
}
}
private void MoveOn()
{
List<Control> order = [HeaderBox];
foreach (Row row in rows)
{
order.Add(row.Time);
order.Add(row.Call);
order.Add(row.Number);
if (!session.Settings.QtcSkipConfirm && row.Confirm.IsVisible)
{
order.Add(row.Confirm);
}
}
int at = order.FindIndex(b => b.IsFocused);
if (at < 0)
{
return;
}
for (int next = at + 1; next < order.Count; next++)
{
if (order[next].IsEffectivelyEnabled)
{
order[next].Focus();
return;
}
}
CloseButton.Focus();
}
/// 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)
{
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);
}
if (session.Settings.QtcHeaderAgainClears)
{
HeaderBox.Text = "";
}
HeaderBox.Focus();
}
private void OnHeaderConfirm(object? sender, RoutedEventArgs e) => rows[0].Time.Focus();
/// Ask for a line again: N1MM sends its Agn message and comes back to the
/// line. Here it clears the line and puts the cursor in it.
private void OnLineAgain(int at)
{
if (isSending)
{
if (IsCw || IsRtty)
{
SendLine(at);
return;
}
rows[at].Time.Focus();
return;
}
if (session.Settings.QtcAgainClearsLine)
{
rows[at].Clear();
}
rows[at].Time.Focus();
Paint();
}
/// Reads one line out and moves to the next: the time, the callsign and the
/// serial number, with the operator's spacing between them. When the last
/// line has gone the cursor lands on Close, which sends the TU message.
private void SendLine(int at)
{
lastSent = at;
_ = send(IsRtty
? QtcMessages.SendOne(session.Settings.QtcRttySpacing, RttyLine(at))
: QtcMessages.Line(
rows[at].Time.Text ?? "",
rows[at].Call.Text ?? "",
rows[at].Number.Text ?? "",
session.Settings.QtcCwFieldSpacing));
for (int next = at + 1; next < rows.Count; next++)
{
if (rows[next].Again.IsEffectivelyEnabled)
{
rows[next].Again.Focus();
return;
}
}
CloseButton.Focus();
}
private string RttyLine(int at) => QtcMessages.RttyLine(
rows[at].Time.Text ?? "",
rows[at].Call.Text ?? "",
rows[at].Number.Text ?? "");
/// N1MM's Send All: the whole series in one message. RTTY runs at 45 baud,
/// so a series is the best part of a minute of transmission, and sending it
/// a line at a time means the operator presses a button between each one
/// while the transmitter is up. The heading, the spacing between the lines
/// and the ending are the operator's, and the message keys the transmitter
/// and drops it again itself.
private void OnSendAll(object? sender, RoutedEventArgs e)
{
List<string> lines = [];
for (int at = 0; at < rows.Count; at++)
{
if (!rows[at].IsEmpty)
{
lines.Add(RttyLine(at));
}
}
string message = QtcMessages.SendAll(
session.Settings.QtcRttySendAllHeading,
session.Settings.QtcRttySendAllEnding,
session.Settings.QtcRttySpacing,
SeriesHeader,
lines);
if (message.Length == 0)
{
StatusText.Text = "nothing to send";
return;
}
lastSent = lines.Count - 1;
_ = send(message);
CloseButton.Focus();
}
/// 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)
{
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,
});
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;
next.Focus();
}
private void OnClear(object? sender, RoutedEventArgs e)
{
foreach (Row row in rows)
{
row.Clear();
}
Fill();
}
/// Close saves what is filled in, which is what N1MM's Close does. A line
/// that cannot be read is left out rather than guessed at.
private void OnClose(object? sender, RoutedEventArgs e)
{
if (QtcTraffic.ReadHeader(HeaderBox.Text ?? "") is not { } header)
{
StatusText.Text = "the header is not a series";
HeaderBox.Focus();
return;
}
List<WaeQtc> lines = [];
for (int at = 0; at < rows.Count; at++)
{
if (!rows[at].IsEmpty && ReadLine(at, header) is { } line)
{
lines.Add(isSending && at < ready.Count ? ready[at] with { Series = header.Series } : line);
}
}
Save(lines);
if (IsCw && session.Settings.QtcCwTu.Trim().Length > 0)
{
_ = send(QtcMessages.Tu(session.Settings.QtcCwTu, HeaderBox.Text ?? ""));
}
Close(true);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
private async void OnSetup(object? sender, RoutedEventArgs e)
{
QtcSetupDialog dialog = new(session.Settings);
if (await dialog.ShowDialog<Settings?>(this) is { } updated)
{
session.Save(updated);
Reset();
FirstBox().Focus();
}
}
/// One row per line, a second apart, so they keep their order in the log.
/// That is what N1MM does with them too.
private void Save(IReadOnlyList<WaeQtc> lines)
{
DateTime at = DateTime.UtcNow;
at = at.AddTicks(-(at.Ticks % TimeSpan.TicksPerSecond));
for (int line = 0; line < lines.Count; line++)
{
Qso row = new()
{
Id = Qso.NewId(),
TimestampUtc = at.AddSeconds(line),
Call = station,
Frequency = position.Frequency,
Mode = position.Mode,
RadioNumber = position.RadioNumber,
ContestName = position.Contest.Name,
ContestNumber = position.Instance.ContestNumber,
Operator = position.Session.Operator.Length > 0
? position.Session.Operator
: position.Me.Callsign,
IsRunQso = position.IsRunning,
};
position.Session.Add(lines[line].ApplyTo(CountryFields.Apply(row, session.Countries)));
}
}
private sealed record Row(
TextBox Time,
TextBox Call,
TextBox Number,
Button Again,
Button Confirm)
{
public bool IsEmpty =>
(Time.Text ?? "").Trim().Length == 0
&& (Call.Text ?? "").Trim().Length == 0
&& (Number.Text ?? "").Trim().Length == 0;
public void Paint(IBrush colour)
{
Time.Background = colour;
Call.Background = colour;
Number.Background = colour;
}
public void Clear()
{
Time.Text = "";
Call.Text = "";
Number.Text = "";
}
public void SetEnabled(bool enabled)
{
Time.IsEnabled = enabled;
Call.IsEnabled = enabled;
Number.IsEnabled = enabled;
Again.IsEnabled = enabled;
Confirm.IsEnabled = enabled;
}
}
}