Take WAE QTC traffic down
Ctrl+Z opens the QTC window for the station being worked, which is the key N1MM puts it on, and the window is laid out the way N1MM lays it out: the header, ten lines of time, callsign and serial number, an Agn and a Cfm button beside each, Clear, Close and Cancel underneath, and the same colours — green for a line that will be saved, red for one still empty, yellow for one that cannot be read. Enter and Tab move forward and space moves within a line, so a line can be walked while listening to it. Sending, the lines are filled from the log and cannot be edited: the contacts not reported to anybody yet, oldest first, never the station being worked, and never past the ten any station may have. The rule for what has been reported is N1MM's — a contact counts as reported when a QTC row carries its call and the serial we sent it — so the pair is kept in the row the way N1MM keeps it. Which way traffic goes is not the operator's choice on CW and SSB: Europe receives and the rest of the world sends. On RTTY it goes both ways, and there the window carries a switch. That is the one piece of the workflow that is ours rather than N1MM's, which toggles a QTC mode outside the window instead. Nothing here transmits. N1MM sends the lines on CW and RTTY and plays recorded voice messages on SSB; we have no QTC message templates, no voice keyer and no digital window, so Agn, Cfm and RX Ready move the cursor and the operator sends by hand. Running it caught what the tests could not: focus set in the constructor does not stick, so the first thing typed into a freshly opened window was dropped and every field after it took the value of the one before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -161,8 +161,14 @@ public sealed class AppSession : IDisposable
|
||||
}
|
||||
activeRadio = Math.Min(activeRadio, positions.Count - 1);
|
||||
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
|
||||
qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
|
||||
Logging.Logged += (_, qso) =>
|
||||
{
|
||||
// a WAE QTC row is traffic about a station, not a station on the air
|
||||
if (contest.IsContact(qso))
|
||||
{
|
||||
Bandmap.Add(new Spot(qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
|
||||
}
|
||||
};
|
||||
Logging.Logged += (_, qso) => _ = network?.SendAsync(qso, Settings.Station.Callsign);
|
||||
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
|
||||
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
|
||||
|
||||
@@ -254,6 +254,10 @@ public sealed partial class EntryWindow : Window
|
||||
e.Handled = true;
|
||||
MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
|
||||
break;
|
||||
case Key.Z when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||
e.Handled = true;
|
||||
_ = OpenQtcWindow();
|
||||
break;
|
||||
case Key.B when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||
e.Handled = true;
|
||||
ToggleAlternatingCq();
|
||||
@@ -456,6 +460,50 @@ public sealed partial class EntryWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// WAE QTC traffic, on Ctrl+Z as in N1MM. The contact is logged first if it
|
||||
/// is still in the boxes: the traffic belongs to a station that has been
|
||||
/// worked.
|
||||
private async Task OpenQtcWindow()
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
QtcTraffic traffic = new(Logging);
|
||||
if (!traffic.IsWaeContest)
|
||||
{
|
||||
Status("QTC traffic is a WAE thing");
|
||||
return;
|
||||
}
|
||||
if (Logging.Entry.Call.Trim().Length > 0 && Logging.Entry.IsComplete)
|
||||
{
|
||||
OnEnter();
|
||||
}
|
||||
string typed = Logging.Entry.Call.Trim();
|
||||
Callsign station = Callsign.Parse(
|
||||
typed.Length > 0 ? typed : Logging.Log.Qsos.LastOrDefault()?.Call.Text ?? "");
|
||||
QtcDirection direction = traffic.DirectionFor(station, out string why);
|
||||
if (direction == QtcDirection.None)
|
||||
{
|
||||
Status(why);
|
||||
return;
|
||||
}
|
||||
if (direction is QtcDirection.Send && traffic.ToSend(station).Count == 0)
|
||||
{
|
||||
Status("nothing left to report to " + station.Text);
|
||||
return;
|
||||
}
|
||||
QtcWindow window = new(session, Logging, station, direction);
|
||||
bool saved = await window.ShowDialog<bool>(this);
|
||||
Logging.Wipe();
|
||||
SyncBoxes();
|
||||
boxes[0].Focus();
|
||||
Refresh();
|
||||
Status(saved
|
||||
? $"QTC traffic with {station.Text} logged"
|
||||
: $"QTC traffic with {station.Text} left alone");
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|
||||
29
src/Nonemm.App/Windows/QtcWindow.axaml
Normal file
29
src/Nonemm.App/Windows/QtcWindow.axaml
Normal file
@@ -0,0 +1,29 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Windows.QtcWindow"
|
||||
Title="QTC" SizeToContent="WidthAndHeight" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<StackPanel Margin="10" Spacing="4">
|
||||
<StackPanel Name="DirectionPanel" Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<RadioButton Name="ReceiveButton" Content="Receive" GroupName="direction" IsChecked="True" />
|
||||
<RadioButton Name="SendButton" Content="Send" GroupName="direction" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="QTC Header :" FontSize="11" Opacity="0.7" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBox Name="HeaderBox" Width="110" />
|
||||
<Button Name="ReadyButton" Content="RX Ready" Click="OnReady" />
|
||||
<Button Name="HeaderAgainButton" Content="Hdr Agn" Click="OnHeaderAgain" />
|
||||
<Button Name="HeaderCfmButton" Content="Cfm" Click="OnHeaderConfirm" />
|
||||
</StackPanel>
|
||||
<Grid Name="Lines" Margin="0,6,0,0" />
|
||||
<TextBlock Name="KeysText" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
||||
<TextBlock FontSize="11" Opacity="0.7" TextWrapping="Wrap" MaxWidth="420"
|
||||
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">
|
||||
<TextBlock Name="StatusText" VerticalAlignment="Center" FontSize="11" Opacity="0.7" />
|
||||
<Button Content="Clear" Click="OnClear" />
|
||||
<Button Name="CloseButton" Content="Close" Click="OnClose" IsDefault="True" />
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
372
src/Nonemm.App/Windows/QtcWindow.axaml.cs
Normal file
372
src/Nonemm.App/Windows/QtcWindow.axaml.cs
Normal file
@@ -0,0 +1,372 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Nonemm.Contests.Rules;
|
||||
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.
|
||||
/// Nothing here transmits: on CW the operator sends from the entry window, on
|
||||
/// SSB by voice, on RTTY from the digital window there is not yet.
|
||||
public sealed partial class QtcWindow : Window
|
||||
{
|
||||
private static readonly IBrush Saved = new SolidColorBrush(Color.FromRgb(0xD4, 0xF5, 0xD4));
|
||||
private static readonly IBrush Unsaved = new SolidColorBrush(Color.FromRgb(0xFF, 0xE4, 0xE1));
|
||||
private static readonly IBrush Malformed = new SolidColorBrush(Color.FromRgb(0xFF, 0xF8, 0xC4));
|
||||
|
||||
private readonly AppSession session;
|
||||
private readonly RadioPosition position;
|
||||
private readonly QtcTraffic traffic;
|
||||
private readonly Callsign station;
|
||||
private bool isSending;
|
||||
private readonly List<Row> rows = [];
|
||||
private readonly List<WaeQtc> ready = [];
|
||||
|
||||
public QtcWindow(
|
||||
AppSession session,
|
||||
RadioPosition position,
|
||||
Callsign station,
|
||||
QtcDirection direction)
|
||||
{
|
||||
this.session = session;
|
||||
this.position = position;
|
||||
this.station = station;
|
||||
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: the header when it has to be typed, the first
|
||||
/// line when it is already filled in.
|
||||
private TextBox FirstBox() => isSending ? rows[0].Time : HeaderBox;
|
||||
|
||||
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 = 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;
|
||||
HeaderCfmButton.IsVisible = !isSending;
|
||||
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 = $"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);
|
||||
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));
|
||||
// the contest is what makes it RTTY, not whatever the radio is on now
|
||||
bool rtty = position.Contest.Modes.Contains(ModeCategory.Digital);
|
||||
HeaderBox.Text = ready.Count == 0
|
||||
? ""
|
||||
: rtty
|
||||
? $"QTC {ready[0].SeriesText} - {station.Text}"
|
||||
: $"QTC {ready[0].SeriesText}";
|
||||
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.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<TextBox> order = [HeaderBox, .. rows.SelectMany(r => new[] { r.Time, r.Call, r.Number })];
|
||||
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();
|
||||
}
|
||||
|
||||
/// N1MM's RX Ready tells the other station to go ahead; with no transmit
|
||||
/// path here it only puts the cursor where the first line will land.
|
||||
private void OnReady(object? sender, RoutedEventArgs e) => rows[0].Time.Focus();
|
||||
|
||||
private void OnHeaderAgain(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
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)
|
||||
{
|
||||
rows[at].Time.Focus();
|
||||
return;
|
||||
}
|
||||
rows[at].Clear();
|
||||
rows[at].Time.Focus();
|
||||
Paint();
|
||||
}
|
||||
|
||||
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);
|
||||
Close(true);
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,18 @@ public sealed record WaeQtc(
|
||||
int CountInSeries,
|
||||
string TimeUtc,
|
||||
Callsign Call,
|
||||
int Number)
|
||||
int Number,
|
||||
int OwnNumber = 0)
|
||||
{
|
||||
public const string Contact = "QSO";
|
||||
public const string Sent = "SQTC";
|
||||
public const string Received = "RQTC";
|
||||
|
||||
/// `Number` is the serial that station sent us in the contact being
|
||||
/// reported, and it is what goes out on the air. `OwnNumber` is the serial
|
||||
/// we sent them, kept only so the same contact is not reported twice;
|
||||
/// N1MM keeps it in the `NR` column for the same reason.
|
||||
///
|
||||
/// Ten lines to a series, and ten to any one station for the whole contest.
|
||||
public const int MostPerSeries = 10;
|
||||
|
||||
@@ -49,7 +55,8 @@ public sealed record WaeQtc(
|
||||
count,
|
||||
qso.SentReport,
|
||||
Callsign.Parse(qso.ReceivedReport),
|
||||
qso.SentNumber);
|
||||
qso.SentNumber,
|
||||
qso.ReceivedNumber);
|
||||
}
|
||||
|
||||
/// Fills this QTC into a log row. The caller supplies the row so the
|
||||
@@ -62,7 +69,7 @@ public sealed record WaeQtc(
|
||||
SentReport = TimeUtc,
|
||||
ReceivedReport = Call.Text,
|
||||
SentNumber = Number,
|
||||
ReceivedNumber = 0,
|
||||
ReceivedNumber = OwnNumber,
|
||||
};
|
||||
|
||||
private static (int Series, int Count) ParseSeries(string text)
|
||||
|
||||
184
src/Nonemm.Session/QtcTraffic.cs
Normal file
184
src/Nonemm.Session/QtcTraffic.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Contests.Rules;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// Which way QTC traffic goes with a station, if it goes at all.
|
||||
public enum QtcDirection
|
||||
{
|
||||
None,
|
||||
Send,
|
||||
Receive,
|
||||
|
||||
/// RTTY, where traffic goes both ways and the operator picks.
|
||||
Either,
|
||||
}
|
||||
|
||||
/// The WAE QTC rules that do not need a window: who may exchange traffic with
|
||||
/// whom, which contacts are still worth reporting, how the series are numbered,
|
||||
/// and whether a line an operator has typed reads as a QTC.
|
||||
///
|
||||
/// On CW and SSB, traffic goes one way only: the station outside Europe reports
|
||||
/// its contacts to the European station. On RTTY it goes both ways, and the
|
||||
/// only rule left is that the two stations are on different continents.
|
||||
public sealed class QtcTraffic
|
||||
{
|
||||
private readonly RadioPosition position;
|
||||
|
||||
public QtcTraffic(RadioPosition position) => this.position = position;
|
||||
|
||||
public bool IsWaeContest => position.Contest is Wae;
|
||||
|
||||
/// Which way traffic would go with this station. `reason` says why not when
|
||||
/// the answer is `None`, in the words the status line shows.
|
||||
public QtcDirection DirectionFor(Callsign other, out string reason)
|
||||
{
|
||||
reason = "";
|
||||
if (position.Contest is not Wae contest)
|
||||
{
|
||||
reason = "QTC traffic is a WAE thing";
|
||||
return QtcDirection.None;
|
||||
}
|
||||
if (other.Text.Length < 3)
|
||||
{
|
||||
reason = "work a station first";
|
||||
return QtcDirection.None;
|
||||
}
|
||||
if (Remaining(other) <= 0)
|
||||
{
|
||||
reason = $"{other.Text} has had its ten QTCs";
|
||||
return QtcDirection.None;
|
||||
}
|
||||
string theirs = ContinentOf(other);
|
||||
bool sameContinent = theirs.Length > 0 && theirs == position.Me.Continent;
|
||||
if (contest.Modes.Contains(ModeCategory.Digital))
|
||||
{
|
||||
if (sameContinent)
|
||||
{
|
||||
reason = $"{other.Text} is on this continent";
|
||||
return QtcDirection.None;
|
||||
}
|
||||
return QtcDirection.Either;
|
||||
}
|
||||
if (position.Me.Continent == "EU")
|
||||
{
|
||||
if (theirs == "EU")
|
||||
{
|
||||
reason = $"{other.Text} is in Europe";
|
||||
return QtcDirection.None;
|
||||
}
|
||||
return QtcDirection.Receive;
|
||||
}
|
||||
if (theirs != "EU")
|
||||
{
|
||||
reason = $"{other.Text} is not in Europe";
|
||||
return QtcDirection.None;
|
||||
}
|
||||
return QtcDirection.Send;
|
||||
}
|
||||
|
||||
/// How many QTC lines have already gone either way with this station. Ten
|
||||
/// is the lot, for the whole contest and whatever the band.
|
||||
public int ExchangedWith(Callsign other) =>
|
||||
position.Log.Qsos.Count(q =>
|
||||
WaeQtc.IsQtc(q) && q.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public int Remaining(Callsign other) =>
|
||||
Math.Max(0, WaeQtc.MostPerStation - ExchangedWith(other));
|
||||
|
||||
/// Series are numbered across the whole contest, not per station: the
|
||||
/// hundredth series a station sends is `100/10` whoever gets it.
|
||||
public int NextSeries()
|
||||
{
|
||||
int last = position.Log.Qsos
|
||||
.Where(q => q.Exchange1.Equals(WaeQtc.Sent, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(q => WaeQtc.From(q)?.Series ?? 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
return last + 1;
|
||||
}
|
||||
|
||||
/// The contacts to report to this station: the ones not reported to anybody
|
||||
/// yet, oldest first, and never the station itself. Ten at a time, fewer
|
||||
/// when it has already had some.
|
||||
public IReadOnlyList<WaeQtc> ToSend(Callsign other)
|
||||
{
|
||||
int room = Math.Min(WaeQtc.MostPerSeries, Remaining(other));
|
||||
if (room <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
HashSet<string> reported = position.Log.Qsos
|
||||
.Where(q => q.Exchange1.Equals(WaeQtc.Sent, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(q => ReportedKey(q.ReceivedReport, q.ReceivedNumber))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
List<Qso> worth = position.Log.Qsos
|
||||
.Where(q => !WaeQtc.IsQtc(q))
|
||||
.Where(q => !q.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(q => !reported.Contains(ReportedKey(q.Call.Text, q.SentNumber)))
|
||||
.OrderBy(q => q.TimestampUtc)
|
||||
.Take(room)
|
||||
.ToList();
|
||||
int series = NextSeries();
|
||||
return [.. worth.Select(q => new WaeQtc(
|
||||
other,
|
||||
IsSent: true,
|
||||
series,
|
||||
worth.Count,
|
||||
q.TimestampUtc.ToString("HHmm"),
|
||||
q.Call,
|
||||
q.ReceivedNumber,
|
||||
q.SentNumber))];
|
||||
}
|
||||
|
||||
/// What the operator has typed, read as a QTC. Null when a field is empty
|
||||
/// or malformed, which is what the window paints yellow.
|
||||
public static WaeQtc? Read(
|
||||
Callsign station,
|
||||
bool isSent,
|
||||
int series,
|
||||
int countInSeries,
|
||||
string time,
|
||||
string call,
|
||||
string number)
|
||||
{
|
||||
string trimmedTime = time.Trim();
|
||||
string trimmedCall = call.Trim().ToUpperInvariant();
|
||||
if (trimmedTime.Length != 4
|
||||
|| !trimmedTime.All(char.IsAsciiDigit)
|
||||
|| !IsClockTime(trimmedTime)
|
||||
|| trimmedCall.Length < 3
|
||||
|| !int.TryParse(number.Trim(), out int parsed)
|
||||
|| parsed <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new WaeQtc(
|
||||
station, isSent, series, countInSeries, trimmedTime, Callsign.Parse(trimmedCall), parsed);
|
||||
}
|
||||
|
||||
/// The header a station sends in front of a series: a series number, a
|
||||
/// separator and how many lines follow. Anything between the two numbers
|
||||
/// counts as the separator, because it is copied off the air.
|
||||
public static (int Series, int Count)? ReadHeader(string text)
|
||||
{
|
||||
string[] parts = text.Trim().ToUpperInvariant().Replace("QTC", "").Trim()
|
||||
.Split(['/', '-', ' ', '\\'], StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length >= 2
|
||||
&& int.TryParse(parts[0], out int series)
|
||||
&& int.TryParse(parts[1], out int count)
|
||||
&& series > 0
|
||||
&& count is > 0 and <= WaeQtc.MostPerSeries
|
||||
? (series, count)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsClockTime(string text) =>
|
||||
int.Parse(text[..2]) < 24 && int.Parse(text[2..]) < 60;
|
||||
|
||||
private static string ReportedKey(string call, int number) => $"{call}|{number}";
|
||||
|
||||
private string ContinentOf(Callsign call) =>
|
||||
position.Session.Countries?.Find(call)?.Continent ?? "";
|
||||
}
|
||||
Reference in New Issue
Block a user