Work QTC traffic on RTTY the way N1MM does
The QTC window put nothing on the air on RTTY except the lines themselves,
so the operator typed the rest into the digital window's transmit pane and
copied the lines by hand. N1MM sends a message for each step and fills the
lines in from a click. Both are here now, with N1MM's messages, N1MM's
defaults and N1MM's settings behind them.
The messages, all of them settings in the QTC window's setup area:
- RX Ready, `WAERXReadyText`, `{ENTERLF}I AM QRV BK`
- All Agn, `WAEAllAgnText`, `{ENTERLF}ALL AGN ALL AGN BK`
- Agn n, `WAEAGNText`, `{ENTERLF}AGN QTC: $ $ $ `, where `$` is the line
- Save, `WAESaveQTCText`, `{ENTERLF}R R R ALL OK BK`
- R U QRV, `WAERUQRVText`, `{ENTERLF}R U QRV BK`
- Save, reading traffic out, `WAESSaveQTCText`, `{ENTERLF}GL DE {MYCALL}`
Each keys the transmitter, sends the text and drops it again, and an empty
one sends nothing. The line spacing default is N1MM's `{ENTERLF}` rather
than `{ENTER}`, which was wrong.
The buttons: R U QRV and Snd Hdr were hidden while reading traffic out on
RTTY, though that is where N1MM starts — R U QRV is the first button
pressed. All Agn is new, and so is the S button on each line, which strikes
a line through a QTC that arrived too broken to read so the rows after it
keep their places; Agn n clears the strike and asks for that line again.
Clicking a line in the digital window fills the next row, which is how the
series is copied on RTTY: the other station sends the header and all ten
lines in one transmission. A click anywhere in a line hands the whole line
to the QTC window, which reads the time, the callsign and the serial number
out of it by shape and flashes the line to show it was taken. A line that is
not a QTC is left to mean what a click always means there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
This commit is contained in:
@@ -122,6 +122,14 @@ public sealed class AppSession : IDisposable
|
||||
|
||||
public CheckWindowSources? Check { get; private set; }
|
||||
|
||||
/// Where a line of received text goes while the QTC window is taking
|
||||
/// traffic down on RTTY. The other station sends the header and the whole
|
||||
/// series in one transmission, and the operator clicks each line in the
|
||||
/// digital window rather than typing it, which is how N1MM works. The QTC
|
||||
/// window sets this while it is open and returns whether the line went
|
||||
/// into it.
|
||||
public Func<string, bool>? QtcLines { get; set; }
|
||||
|
||||
/// What is spotted and not worked yet.
|
||||
public AvailableStations? Available { get; private set; }
|
||||
|
||||
|
||||
@@ -208,6 +208,25 @@ public sealed record Settings
|
||||
|
||||
public string QtcRttySendAllEnding { get; init; } = QtcMessages.DefaultSendAllEnding;
|
||||
|
||||
/// What goes out when the station reading traffic out closes the window,
|
||||
/// which is N1MM's `WAESSaveQTCText`.
|
||||
public string QtcRttySendSave { get; init; } = QtcMessages.DefaultRttySendSave;
|
||||
|
||||
/// What the station reading traffic out asks before it starts, N1MM's
|
||||
/// `WAERUQRVText`.
|
||||
public string QtcRttyAreYouReady { get; init; } = QtcMessages.DefaultRttyAreYouReady;
|
||||
|
||||
/// The four messages of the station taking traffic down, N1MM's
|
||||
/// `WAERXReadyText`, `WAEAllAgnText`, `WAEAGNText` and `WAESaveQTCText`.
|
||||
/// `$` in the line message stands for the line being asked for.
|
||||
public string QtcRttyReady { get; init; } = QtcMessages.DefaultRttyReady;
|
||||
|
||||
public string QtcRttyAllAgain { get; init; } = QtcMessages.DefaultRttyAllAgain;
|
||||
|
||||
public string QtcRttyLineAgain { get; init; } = QtcMessages.DefaultRttyLineAgain;
|
||||
|
||||
public string QtcRttySave { get; init; } = QtcMessages.DefaultRttySave;
|
||||
|
||||
/// The log window's columns, as `QsoField` names left to right. Empty means
|
||||
/// the set the contest opens with.
|
||||
public IReadOnlyList<string> LogColumns { get; init; } = [];
|
||||
|
||||
@@ -33,19 +33,36 @@
|
||||
</Grid>
|
||||
<TextBlock Text="The spacing goes between the fields of a QTC line, written N1MM's way with S for a space. The three again messages go out on shift and Enter in that box while taking traffic down. TU goes out when the window closes after reading a series out; empty sends nothing."
|
||||
FontSize="11" Opacity="0.7" TextWrapping="Wrap" Margin="0,-2,0,0" />
|
||||
<TextBlock Text="RTTY messages" FontWeight="Bold" Margin="0,10,0,0" />
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto">
|
||||
<TextBlock Text="Line spacing" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||
<TextBox Grid.Column="1" Name="RttySpacingBox" Margin="0,2" />
|
||||
<TextBlock Text="RTTY messages, reading traffic out" FontWeight="Bold" Margin="0,10,0,0" />
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||
<TextBlock Text="R U QRV" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||
<TextBox Grid.Column="1" Name="RttyAreYouReadyBox" Margin="0,2" />
|
||||
<TextBlock Grid.Row="1" Text="Send All heading" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Name="SendAllHeadingBox" Margin="0,2" />
|
||||
<TextBlock Grid.Row="2" Text="Send All ending" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Name="SendAllEndingBox" Margin="0,2" />
|
||||
<TextBlock Grid.Row="3" Text="Save" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Name="RttySendSaveBox" Margin="0,2" />
|
||||
<TextBlock Grid.Row="4" Text="Line spacing" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||
<TextBox Grid.Row="4" Grid.Column="1" Name="RttySpacingBox" Margin="0,2" />
|
||||
</Grid>
|
||||
<TextBlock Text="These go out when the All button reads a whole series out on RTTY. The spacing goes between the lines, not between the fields of one — use {ENTER} or {ENTERLF} for a carriage return. {QTC} stands for the header of the series, and the other function key macros work as well."
|
||||
<TextBlock Text="The spacing goes between the lines of a series, not between the fields of one — use {ENTER} or {ENTERLF} for a carriage return. {QTC} stands for the header of the series, and the other function key macros work as well."
|
||||
FontSize="11" Opacity="0.7" TextWrapping="Wrap" Margin="0,-2,0,0" />
|
||||
<TextBlock Text="RTTY messages, taking traffic down" FontWeight="Bold" Margin="0,10,0,0" />
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,Auto">
|
||||
<TextBlock Text="RX Ready" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||
<TextBox Grid.Column="1" Name="RttyReadyBox" Margin="0,2" />
|
||||
<TextBlock Grid.Row="1" Text="All Agn" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Name="RttyAllAgainBox" Margin="0,2" />
|
||||
<TextBlock Grid.Row="2" Text="Agn n" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Name="RttyLineAgainBox" Margin="0,2" />
|
||||
<TextBlock Grid.Row="3" Text="Save" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Name="RttySaveBox" Margin="0,2" />
|
||||
</Grid>
|
||||
<TextBlock Text="$ in the Agn n message stands for the line being asked for, so AGN QTC: $ $ $ goes out as AGN QTC: 5 5 5. Each of these keys the transmitter, sends the message and drops it again; an empty one sends nothing."
|
||||
FontSize="11" Opacity="0.7" TextWrapping="Wrap" Margin="0,-2,0,0" />
|
||||
<TextBlock Name="MissingText" FontSize="11" Opacity="0.7" TextWrapping="Wrap" Margin="0,6,0,0"
|
||||
Text="N1MM's SSB recordings are left out: playing those needs a voice keyer, and this program has none. So are its RTTY messages for the station taking traffic down — RX Ready, All Agn, Agn and Save — which are typed into the digital window's transmit pane here." />
|
||||
Text="N1MM's SSB recordings are left out: playing those needs a voice keyer, and this program has none." />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
|
||||
@@ -27,6 +27,12 @@ public sealed partial class QtcSetupDialog : Window
|
||||
RttySpacingBox.Text = settings.QtcRttySpacing;
|
||||
SendAllHeadingBox.Text = settings.QtcRttySendAllHeading;
|
||||
SendAllEndingBox.Text = settings.QtcRttySendAllEnding;
|
||||
RttyAreYouReadyBox.Text = settings.QtcRttyAreYouReady;
|
||||
RttySendSaveBox.Text = settings.QtcRttySendSave;
|
||||
RttyReadyBox.Text = settings.QtcRttyReady;
|
||||
RttyAllAgainBox.Text = settings.QtcRttyAllAgain;
|
||||
RttyLineAgainBox.Text = settings.QtcRttyLineAgain;
|
||||
RttySaveBox.Text = settings.QtcRttySave;
|
||||
}
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
|
||||
@@ -46,6 +52,12 @@ public sealed partial class QtcSetupDialog : Window
|
||||
QtcRttySpacing = RttySpacingBox.Text ?? "",
|
||||
QtcRttySendAllHeading = SendAllHeadingBox.Text ?? "",
|
||||
QtcRttySendAllEnding = SendAllEndingBox.Text ?? "",
|
||||
QtcRttyAreYouReady = RttyAreYouReadyBox.Text ?? "",
|
||||
QtcRttySendSave = RttySendSaveBox.Text ?? "",
|
||||
QtcRttyReady = RttyReadyBox.Text ?? "",
|
||||
QtcRttyAllAgain = RttyAllAgainBox.Text ?? "",
|
||||
QtcRttyLineAgain = RttyLineAgainBox.Text ?? "",
|
||||
QtcRttySave = RttySaveBox.Text ?? "",
|
||||
});
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||
|
||||
@@ -3,7 +3,9 @@ using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using Nonemm.App.Theming;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
@@ -229,6 +231,10 @@ public sealed partial class DigitalWindow
|
||||
RightClick(e);
|
||||
return;
|
||||
}
|
||||
if (TakenAsQtc(e))
|
||||
{
|
||||
return;
|
||||
}
|
||||
string clicked = e.Source is TextBlock block ? DigitalReceiver.Scrub(block.Text ?? "") : "";
|
||||
if (clicked.Length == 0)
|
||||
{
|
||||
@@ -250,6 +256,51 @@ public sealed partial class DigitalWindow
|
||||
Copy(clicked, keys.HasFlag(KeyModifiers.Alt), e.ClickCount > 1);
|
||||
}
|
||||
|
||||
/// While the QTC window is taking traffic down on RTTY, a click anywhere in
|
||||
/// a line hands the whole line to it rather than picking the word out: the
|
||||
/// other station sends the header and the whole series in one
|
||||
/// transmission, and the operator clicks each line as it prints. The line
|
||||
/// flashes to show which one was taken, which is what N1MM does with it.
|
||||
/// False when there is no QTC window, or the line is not a QTC, and then
|
||||
/// the click means what it always means.
|
||||
private bool TakenAsQtc(PointerPressedEventArgs e)
|
||||
{
|
||||
if (session.QtcLines is not { } take || LineUnder(e.Source as Visual) is not { } border)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string text = DigitalReceiver.Scrub(string.Concat(
|
||||
Body(border).Children.OfType<TextBlock>().Select(b => b.Text ?? "")));
|
||||
if (text.Trim().Length == 0 || !take(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Flash(border);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The line a clicked control belongs to: the border a printed line lives
|
||||
/// in, walking up from whatever was under the pointer.
|
||||
private static Border? LineUnder(Visual? clicked)
|
||||
{
|
||||
for (Visual? at = clicked; at is not null; at = at.GetVisualParent())
|
||||
{
|
||||
if (at is Border border && border.Child is WrapPanel)
|
||||
{
|
||||
return border;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A moment of colour behind the line that was taken.
|
||||
private static void Flash(Border border)
|
||||
{
|
||||
IBrush was = border.Background ?? Brushes.Transparent;
|
||||
border.Background = Themes.Brush(Themes.Current.QuickEdit);
|
||||
DispatcherTimer.RunOnce(() => border.Background = was, TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
/// Where a clicked word goes. N1MM's order: an empty callsign box takes a
|
||||
/// call, a callsign box that already holds one takes another only onto the
|
||||
/// call stack or on a double click, and everything else is an exchange
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<TextBox Name="HeaderBox" Width="170" />
|
||||
<Button Name="ReadyButton" Content="RX Ready" Click="OnReady" />
|
||||
<Button Name="HeaderAgainButton" Content="Hdr Agn" Click="OnHeaderAgain" />
|
||||
<Button Name="AllAgainButton" Content="All Agn" Click="OnAllAgain" />
|
||||
<Button Name="HeaderCfmButton" Content="Cfm" Click="OnHeaderConfirm" />
|
||||
<Button Name="SendAllButton" Content="Send All" Click="OnSendAll" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -77,7 +77,11 @@ public sealed partial class QtcWindow : Window
|
||||
Reset();
|
||||
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
|
||||
HeaderBox.LostFocus += (_, _) => ApplyHeaderLimit();
|
||||
Closed += (_, _) => ShowCall(null);
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
ShowCall(null);
|
||||
session.QtcLines = null;
|
||||
};
|
||||
// focus set before the window is on screen does not stick
|
||||
Opened += (_, _) => FirstBox().Focus();
|
||||
}
|
||||
@@ -112,6 +116,13 @@ public sealed partial class QtcWindow : Window
|
||||
|
||||
private bool IsRtty => position.Contest.Modes.Contains(ModeCategory.Digital);
|
||||
|
||||
/// Whether this window puts anything on the air. SSB needs a voice keyer
|
||||
/// and there is none here, so on SSB the operator speaks and the window
|
||||
/// only writes the traffic down.
|
||||
private bool Keys => IsCw || IsRtty;
|
||||
|
||||
private Settings Settings => session.Settings;
|
||||
|
||||
/// 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.
|
||||
@@ -131,15 +142,22 @@ public sealed partial class QtcWindow : Window
|
||||
/// Everything that follows from which way the traffic goes.
|
||||
private void Reset()
|
||||
{
|
||||
// taking traffic down on RTTY, a line clicked in the digital window
|
||||
// fills the next row, which is how the series is copied there
|
||||
session.QtcLines = !isSending && IsRtty ? TakeLine : null;
|
||||
Title = isSending ? $"Send QTC · {station.Text}" : $"Receive QTC · {station.Text}";
|
||||
CountsText.Text = traffic.CountsFor(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;
|
||||
ReadyButton.IsVisible = Keys || !isSending;
|
||||
ReadyButton.Content = !IsCw ? isSending ? "R U QRV" : "RX Ready"
|
||||
: isSending ? "R U QRV" : "QRV";
|
||||
HeaderAgainButton.Content = isSending ? "Snd Hdr" : "Hdr Agn";
|
||||
HeaderAgainButton.IsVisible = !isSending || Keys;
|
||||
// N1MM's All Agn, which asks for the whole series again. It is one
|
||||
// message, so it is RTTY only
|
||||
AllAgainButton.IsVisible = !isSending && IsRtty;
|
||||
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
|
||||
@@ -157,7 +175,7 @@ public sealed partial class QtcWindow : Window
|
||||
Lines.RowDefinitions.Clear();
|
||||
Lines.Children.Clear();
|
||||
rows.Clear();
|
||||
foreach (string width in new[] { "60", "120", "60", "Auto", "Auto" })
|
||||
foreach (string width in new[] { "60", "120", "60", "Auto", "Auto", "Auto" })
|
||||
{
|
||||
Lines.ColumnDefinitions.Add(new ColumnDefinition(
|
||||
width == "Auto" ? GridLength.Auto : new GridLength(double.Parse(width))));
|
||||
@@ -174,19 +192,28 @@ public sealed partial class QtcWindow : Window
|
||||
Margin = new Avalonia.Thickness(2, 1, 2, 1),
|
||||
};
|
||||
Button confirm = new() { Content = $"Cfm{at + 1}", Margin = new Avalonia.Thickness(2, 1, 2, 1) };
|
||||
Button skip = new() { Content = "S", Margin = new Avalonia.Thickness(2, 1, 2, 1) };
|
||||
int line = at;
|
||||
again.Click += (_, _) => OnLineAgain(line);
|
||||
confirm.Click += (_, _) => OnLineConfirm(line);
|
||||
skip.Click += (_, _) => OnSkip(line);
|
||||
// nothing to confirm while reading our own log out, which is how
|
||||
// N1MM hides the column
|
||||
confirm.IsVisible = !isSending;
|
||||
// N1MM's skip button, for a line that arrived too broken to read.
|
||||
// It belongs to the RTTY way of working, where the lines come in
|
||||
// one transmission and the rows have to stay in step with them
|
||||
skip.IsVisible = !isSending && IsRtty;
|
||||
Grid.SetRow(again, at);
|
||||
Grid.SetColumn(again, 3);
|
||||
Grid.SetRow(confirm, at);
|
||||
Grid.SetColumn(confirm, 4);
|
||||
Grid.SetRow(skip, at);
|
||||
Grid.SetColumn(skip, 5);
|
||||
Lines.Children.Add(again);
|
||||
Lines.Children.Add(confirm);
|
||||
rows.Add(new Row(time, call, number, again, confirm));
|
||||
Lines.Children.Add(skip);
|
||||
rows.Add(new Row(time, call, number, again, confirm, skip));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +307,40 @@ public sealed partial class QtcWindow : Window
|
||||
Paint();
|
||||
}
|
||||
|
||||
/// A line of received text the operator clicked in the digital window. The
|
||||
/// header goes in the header box, and a line goes in the first row that is
|
||||
/// still to come, so the rows stay in step with the series even when a
|
||||
/// line was skipped. False when the line is not a QTC or there is no room
|
||||
/// left, and then the digital window treats the click as it always does.
|
||||
private bool TakeLine(string text)
|
||||
{
|
||||
if ((HeaderBox.Text ?? "").Trim().Length == 0
|
||||
&& QtcTraffic.ReadHeader(text) is not null)
|
||||
{
|
||||
HeaderBox.Text = text.Trim().ToUpperInvariant();
|
||||
ApplyHeaderLimit();
|
||||
Paint();
|
||||
return true;
|
||||
}
|
||||
if (QtcTraffic.SplitLine(text) is not { } line)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (int at = 0; at < rows.Count && at < activeLines; at++)
|
||||
{
|
||||
if (!rows[at].IsEmpty || rows[at].IsStruck)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
rows[at].Time.Text = line.Time;
|
||||
rows[at].Call.Text = line.Call;
|
||||
rows[at].Number.Text = line.Number;
|
||||
Paint();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Narrows the live lines to what the header asks for. `4/5` is series 4
|
||||
/// with five QTCs in it, so five lines take traffic and the other five are
|
||||
/// switched off. A station with fewer than five of its ten left gets that
|
||||
@@ -435,7 +496,13 @@ public sealed partial class QtcWindow : Window
|
||||
{
|
||||
_ = send(isSending ? QtcMessages.AreYouReady : QtcMessages.ReadyToReceive);
|
||||
}
|
||||
Control next = isSending ? rows[0].Again : rows[0].Time;
|
||||
else if (IsRtty)
|
||||
{
|
||||
_ = send(QtcMessages.Rtty(
|
||||
isSending ? Settings.QtcRttyAreYouReady : Settings.QtcRttyReady));
|
||||
}
|
||||
Control next = isSending ? SendAllButton.IsVisible ? SendAllButton : rows[0].Again
|
||||
: HeaderBox;
|
||||
next.Focus();
|
||||
}
|
||||
|
||||
@@ -445,7 +512,9 @@ public sealed partial class QtcWindow : Window
|
||||
{
|
||||
if (isSending)
|
||||
{
|
||||
_ = send(HeaderBox.Text ?? "");
|
||||
// the box shows the station callsign after the header on RTTY, and
|
||||
// that is for the operator rather than for the air
|
||||
_ = send(IsRtty ? QtcMessages.Rtty(SeriesHeader) : HeaderBox.Text ?? "");
|
||||
rows[0].Again.Focus();
|
||||
return;
|
||||
}
|
||||
@@ -453,6 +522,10 @@ public sealed partial class QtcWindow : Window
|
||||
{
|
||||
_ = send(QtcMessages.HeaderAgain);
|
||||
}
|
||||
else if (IsRtty)
|
||||
{
|
||||
_ = send(QtcMessages.Rtty(QtcMessages.RttyHeaderAgain));
|
||||
}
|
||||
if (session.Settings.QtcHeaderAgainClears)
|
||||
{
|
||||
HeaderBox.Text = "";
|
||||
@@ -462,6 +535,19 @@ public sealed partial class QtcWindow : Window
|
||||
|
||||
private void OnHeaderConfirm(object? sender, RoutedEventArgs e) => rows[0].Time.Focus();
|
||||
|
||||
/// N1MM's All Agn: asks for the header and every line of the series again,
|
||||
/// and clears what was taken down so the series can be copied afresh.
|
||||
private void OnAllAgain(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = send(QtcMessages.Rtty(Settings.QtcRttyAllAgain));
|
||||
foreach (Row row in rows)
|
||||
{
|
||||
row.Clear();
|
||||
}
|
||||
HeaderBox.Focus();
|
||||
Paint();
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -476,10 +562,27 @@ public sealed partial class QtcWindow : Window
|
||||
rows[at].Time.Focus();
|
||||
return;
|
||||
}
|
||||
if (session.Settings.QtcAgainClearsLine)
|
||||
// N1MM asks for the line by number — `AGN QTC: 5 5 5` on RTTY, `AGN
|
||||
// QTC 5` on CW — and the RTTY message always clears the line, because
|
||||
// the line is copied again from the top
|
||||
if (IsRtty)
|
||||
{
|
||||
_ = send(QtcMessages.Rtty(
|
||||
QtcMessages.LineAgain(Settings.QtcRttyLineAgain, at + 1),
|
||||
Settings.QtcRttySpacing));
|
||||
rows[at].Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (IsCw)
|
||||
{
|
||||
_ = send($"AGN QTC {at + 1}");
|
||||
}
|
||||
if (Settings.QtcAgainClearsLine)
|
||||
{
|
||||
rows[at].Clear();
|
||||
}
|
||||
}
|
||||
rows[at].Time.Focus();
|
||||
Paint();
|
||||
}
|
||||
@@ -597,6 +700,19 @@ public sealed partial class QtcWindow : Window
|
||||
rows.FirstOrDefault(r => r.Time.IsFocused || r.Call.IsFocused || r.Number.IsFocused)
|
||||
?? rows.FirstOrDefault();
|
||||
|
||||
/// N1MM's S button: the line was too broken to read, so it is struck
|
||||
/// through and the ones after it keep their places. Agn n clears the
|
||||
/// strike and asks for the line again.
|
||||
private void OnSkip(int at)
|
||||
{
|
||||
rows[at].Skip();
|
||||
if (at + 1 < rows.Count && at + 1 < activeLines)
|
||||
{
|
||||
rows[at + 1].Time.Focus();
|
||||
}
|
||||
Paint();
|
||||
}
|
||||
|
||||
private void OnLineConfirm(int at)
|
||||
{
|
||||
Control next = at + 1 < rows.Count ? rows[at + 1].Time : CloseButton;
|
||||
@@ -631,9 +747,17 @@ public sealed partial class QtcWindow : Window
|
||||
}
|
||||
}
|
||||
Save(lines);
|
||||
if (IsCw && session.Settings.QtcCwTu.Trim().Length > 0)
|
||||
// N1MM sends a message as the window closes: the TU on CW, and on RTTY
|
||||
// `R R R ALL OK BK` from the station that took the traffic down or
|
||||
// `GL DE <call>` from the one that read it out
|
||||
if (IsCw && Settings.QtcCwTu.Trim().Length > 0)
|
||||
{
|
||||
_ = send(QtcMessages.Tu(session.Settings.QtcCwTu, HeaderBox.Text ?? ""));
|
||||
_ = send(QtcMessages.Tu(Settings.QtcCwTu, HeaderBox.Text ?? ""));
|
||||
}
|
||||
else if (IsRtty)
|
||||
{
|
||||
_ = send(QtcMessages.Rtty(
|
||||
isSending ? Settings.QtcRttySendSave : Settings.QtcRttySave));
|
||||
}
|
||||
Close(true);
|
||||
}
|
||||
@@ -683,12 +807,30 @@ public sealed partial class QtcWindow : Window
|
||||
TextBox Call,
|
||||
TextBox Number,
|
||||
Button Again,
|
||||
Button Confirm)
|
||||
Button Confirm,
|
||||
Button SkipButton)
|
||||
{
|
||||
/// The mark a skipped line carries in each of its boxes, which is
|
||||
/// N1MM's line through the fields.
|
||||
private const string Struck = "-";
|
||||
|
||||
/// A line that was skipped holds nothing worth logging, so it counts
|
||||
/// as empty: it is not saved, and the window paints it as a line still
|
||||
/// to come.
|
||||
public bool IsEmpty =>
|
||||
(Time.Text ?? "").Trim().Length == 0
|
||||
&& (Call.Text ?? "").Trim().Length == 0
|
||||
&& (Number.Text ?? "").Trim().Length == 0;
|
||||
IsStruck
|
||||
|| ((Time.Text ?? "").Trim().Length == 0
|
||||
&& (Call.Text ?? "").Trim().Length == 0
|
||||
&& (Number.Text ?? "").Trim().Length == 0);
|
||||
|
||||
public bool IsStruck => (Time.Text ?? "").Trim() == Struck;
|
||||
|
||||
public void Skip()
|
||||
{
|
||||
Time.Text = Struck;
|
||||
Call.Text = Struck;
|
||||
Number.Text = Struck;
|
||||
}
|
||||
|
||||
/// A null colour is a line that is switched off, which keeps the
|
||||
/// window's own background rather than any of the three.
|
||||
@@ -721,6 +863,7 @@ public sealed partial class QtcWindow : Window
|
||||
Number.IsEnabled = enabled;
|
||||
Again.IsEnabled = enabled;
|
||||
Confirm.IsEnabled = enabled;
|
||||
SkipButton.IsEnabled = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
@@ -36,7 +37,29 @@ public static class QtcMessages
|
||||
/// What goes between the QTC lines on RTTY. N1MM keeps it as a macro rather
|
||||
/// than as characters, so an operator can put a carriage return there,
|
||||
/// which is what its default does.
|
||||
public const string DefaultRttySpacing = "{ENTER}";
|
||||
public const string DefaultRttySpacing = "{ENTERLF}";
|
||||
|
||||
/// The RTTY messages, with N1MM's defaults and the settings they come from
|
||||
/// in its own QTC setup window. Taking traffic down: `WAERXReadyText`,
|
||||
/// `WAEAllAgnText`, `WAEAGNText` and `WAESaveQTCText`. Reading it out:
|
||||
/// `WAERUQRVText` and `WAESSaveQTCText`.
|
||||
public const string DefaultRttyReady = "{ENTERLF}I AM QRV BK";
|
||||
|
||||
public const string DefaultRttyAllAgain = "{ENTERLF}ALL AGN ALL AGN BK";
|
||||
|
||||
/// `$` stands for the line asked for, so this goes out as
|
||||
/// `AGN QTC: 5 5 5`.
|
||||
public const string DefaultRttyLineAgain = "{ENTERLF}AGN QTC: $ $ $ ";
|
||||
|
||||
public const string DefaultRttySave = "{ENTERLF}R R R ALL OK BK";
|
||||
|
||||
public const string DefaultRttyAreYouReady = "{ENTERLF}R U QRV BK";
|
||||
|
||||
public const string DefaultRttySendSave = "{ENTERLF}GL DE {MYCALL}";
|
||||
|
||||
/// Asking for the header again. N1MM writes this into the program rather
|
||||
/// than into its settings on RTTY as well as on CW.
|
||||
public const string RttyHeaderAgain = "HDR AGN";
|
||||
|
||||
/// What stands in front of the series and what closes it. `{QTC}` stands
|
||||
/// for the header, so the default reads the series out twice for a station
|
||||
@@ -86,6 +109,18 @@ public static class QtcMessages
|
||||
public static string SendOne(string spacing, string line) =>
|
||||
$"{{TX}}{spacing}{line}{spacing}{{RX}}";
|
||||
|
||||
/// One of the short RTTY messages: it keys the transmitter, sends the text
|
||||
/// and drops it again, which is what N1MM wraps every one of them in. An
|
||||
/// empty message sends nothing at all.
|
||||
public static string Rtty(string message, string spacing = "") =>
|
||||
message.Trim().Length == 0 ? "" : $"{{TX}}{message}{spacing}{{RX}}";
|
||||
|
||||
/// The line-again message with the line number filled in. N1MM writes the
|
||||
/// number wherever the message has a `$`, so `AGN QTC: $ $ $` asks for
|
||||
/// line five as `AGN QTC: 5 5 5`.
|
||||
public static string LineAgain(string message, int line) =>
|
||||
message.Replace("$", line.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal);
|
||||
|
||||
public static string Spacing(string setting) =>
|
||||
setting.Replace("S", " ", StringComparison.Ordinal);
|
||||
|
||||
|
||||
@@ -235,6 +235,60 @@ public sealed class QtcTraffic
|
||||
: null;
|
||||
}
|
||||
|
||||
/// A line of received text read as the three fields of a QTC. On RTTY the
|
||||
/// other station sends the whole series in one transmission and the
|
||||
/// operator clicks the line rather than typing it, so what arrives here is
|
||||
/// a printed line with whatever the receiver made of the noise around it:
|
||||
/// `1435-DL1AAA-042`, or the same with spaces or slashes between.
|
||||
///
|
||||
/// The fields are taken by shape, in the order they are sent: the first
|
||||
/// group of digits is the time, the first thing shaped like a callsign is
|
||||
/// the callsign, and the last group of digits is the serial number. Null
|
||||
/// when the line does not hold all three.
|
||||
public static (string Time, string Call, string Number)? SplitLine(string text)
|
||||
{
|
||||
string[] parts = text.ToUpperInvariant()
|
||||
.Split(['-', '/', ' ', '\t', ',', '\\'], StringSplitOptions.RemoveEmptyEntries);
|
||||
string time = "";
|
||||
string call = "";
|
||||
string number = "";
|
||||
foreach (string part in parts)
|
||||
{
|
||||
string token = part.Trim();
|
||||
if (token.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (token.All(char.IsAsciiDigit))
|
||||
{
|
||||
if (time.Length == 0 && token.Length is 3 or 4)
|
||||
{
|
||||
time = token;
|
||||
}
|
||||
else if (call.Length > 0)
|
||||
{
|
||||
number = token;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (call.Length == 0 && time.Length > 0 && LooksLikeCall(token))
|
||||
{
|
||||
call = token;
|
||||
}
|
||||
}
|
||||
return time.Length > 0 && call.Length > 0 && number.Length > 0
|
||||
? (time, call, number)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// Enough of a callsign to take off the air: letters and digits, at least
|
||||
/// one of each, three characters or more.
|
||||
private static bool LooksLikeCall(string token) =>
|
||||
token.Length is >= 3 and <= 12
|
||||
&& token.All(c => char.IsAsciiLetterOrDigit(c) || c == '/')
|
||||
&& token.Any(char.IsAsciiDigit)
|
||||
&& token.Any(char.IsAsciiLetter);
|
||||
|
||||
/// A time typed short is filled in from the line above, which is what N1MM
|
||||
/// does when the cursor leaves the box: `52` under `1148` reads as `1152`.
|
||||
/// As many characters are taken off the front of the line above as the
|
||||
|
||||
@@ -59,9 +59,9 @@ public class QtcMessagesTests
|
||||
["1234-DL1ABC-123", "1240-G3XYZ-124"]);
|
||||
|
||||
Assert.Equal(
|
||||
"{TX}{ENTERLF}QTC 3/10 QTC 3/10{ENTER}"
|
||||
+ "1234-DL1ABC-123 {ENTER}"
|
||||
+ "1240-G3XYZ-124 {ENTER}"
|
||||
"{TX}{ENTERLF}QTC 3/10 QTC 3/10{ENTERLF}"
|
||||
+ "1234-DL1ABC-123 {ENTERLF}"
|
||||
+ "1240-G3XYZ-124 {ENTERLF}"
|
||||
+ "{ENTERLF}QSL?? BK DE {MYCALL} K{RX}",
|
||||
message);
|
||||
}
|
||||
@@ -84,10 +84,31 @@ public class QtcMessagesTests
|
||||
public void OneLineKeysAndDropsTheTransmitterOfItsOwn()
|
||||
{
|
||||
Assert.Equal(
|
||||
"{TX}{ENTER}1234-DL1ABC-123{ENTER}{RX}",
|
||||
"{TX}{ENTERLF}1234-DL1ABC-123{ENTERLF}{RX}",
|
||||
QtcMessages.SendOne(QtcMessages.DefaultRttySpacing, "1234-DL1ABC-123"));
|
||||
}
|
||||
|
||||
/// The short RTTY messages: N1MM keys the transmitter, sends the text and
|
||||
/// drops it again for each of them.
|
||||
[Fact]
|
||||
public void AShortRttyMessageKeysAndDropsTheTransmitter()
|
||||
{
|
||||
Assert.Equal(
|
||||
"{TX}{ENTERLF}I AM QRV BK{RX}",
|
||||
QtcMessages.Rtty(QtcMessages.DefaultRttyReady));
|
||||
Assert.Equal(
|
||||
"{TX}{ENTERLF}AGN QTC: 5 5 5 {ENTERLF}{RX}",
|
||||
QtcMessages.Rtty(
|
||||
QtcMessages.LineAgain(QtcMessages.DefaultRttyLineAgain, 5),
|
||||
QtcMessages.DefaultRttySpacing));
|
||||
}
|
||||
|
||||
/// A message the operator has emptied sends nothing at all, which is
|
||||
/// N1MM's "Not Set".
|
||||
[Fact]
|
||||
public void AnEmptyRttyMessageSendsNothing() =>
|
||||
Assert.Equal("", QtcMessages.Rtty(" "));
|
||||
|
||||
[Fact]
|
||||
public void WhatIsInTheBoxesIsTrimmed()
|
||||
{
|
||||
|
||||
@@ -314,6 +314,36 @@ public class QtcTrafficTests
|
||||
Assert.Null(QtcTraffic.Read(
|
||||
Callsign.Parse("NE1C"), isSent: false, 87, 10, time, call, number));
|
||||
|
||||
/// A line clicked in the digital window, read as the three fields. What
|
||||
/// arrives is a printed line with whatever the receiver made of the noise
|
||||
/// around it.
|
||||
[Theory]
|
||||
[InlineData("1435-DL1AAA-042", "1435", "DL1AAA", "042")]
|
||||
[InlineData("1435 DL1AAA 42", "1435", "DL1AAA", "42")]
|
||||
[InlineData(" 1435/DL1AAA/042 ", "1435", "DL1AAA", "042")]
|
||||
[InlineData("1435-2E0GYI-1", "1435", "2E0GYI", "1")]
|
||||
public void AClickedLineIsReadAsAQtc(string text, string time, string call, string number)
|
||||
{
|
||||
(string Time, string Call, string Number) line =
|
||||
Assert.IsType<(string, string, string)>(QtcTraffic.SplitLine(text));
|
||||
|
||||
Assert.Equal(time, line.Time);
|
||||
Assert.Equal(call, line.Call);
|
||||
Assert.Equal(number, line.Number);
|
||||
}
|
||||
|
||||
/// A line missing a field is not taken, and neither is an ordinary
|
||||
/// exchange: the click then means what it always means in the digital
|
||||
/// window.
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("QTC 3/10")]
|
||||
[InlineData("1435-DL1AAA")]
|
||||
[InlineData("DL1AAA 599 042")]
|
||||
[InlineData("CQ CQ DE DL1AAA DL1AAA K")]
|
||||
public void ALineThatIsNotAQtcIsNotTaken(string text) =>
|
||||
Assert.Null(QtcTraffic.SplitLine(text));
|
||||
|
||||
/// The header is copied off the air, so whatever separates the two numbers
|
||||
/// is taken as the separator.
|
||||
[Theory]
|
||||
|
||||
Reference in New Issue
Block a user