Files
Nonemm/src/Nonemm.Session/QtcTraffic.cs
T
erikbrockoandClaude Opus 5 f24cab0ae5 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
2026-09-10 20:03:18 +00:00

332 lines
13 KiB
C#

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 QTC tally for one station, as N1MM writes it beside the callsign:
/// how many lines have already gone with it, how many more may go, and how
/// many logged contacts are left to report to anybody.
public sealed record QtcCounts(QtcDirection Role, int Exchanged, int LeftWith, int Available)
{
/// N1MM's wording, one line per role. Europe on CW and SSB only takes
/// traffic down, so there is no pile of contacts to report and the line
/// leaves that number out.
public string Text => Role switch
{
QtcDirection.Receive => $"QTCs: {Exchanged} rcvd/{LeftWith} left to exch.",
QtcDirection.Send =>
$"QTCs: {Exchanged} sent/{LeftWith} left to exch/{Available} total QTCs avail",
QtcDirection.Either =>
$"QTCs: {Exchanged} exch/{LeftWith} left to exch/{Available} total QTCs avail",
_ => "",
};
/// N1MM puts this in the status bar as well when a station has had its ten.
public bool IsFull => Exchanged >= WaeQtc.MostPerStation;
}
/// 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 OperatingPosition position;
public QtcTraffic(OperatingPosition 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));
/// Which way traffic goes here whoever is worked: Europe takes it down on
/// CW and SSB, the rest of the world reads it out, and on RTTY it goes
/// both ways. `DirectionFor` answers the same question about one station;
/// this one ignores the station, so the counts still read out when the
/// callsign box is empty or holds a station with nothing left.
public QtcDirection Role
{
get
{
if (position.Contest is not Wae contest)
{
return QtcDirection.None;
}
if (contest.Modes.Contains(ModeCategory.Digital))
{
return QtcDirection.Either;
}
return position.Me.Continent == "EU" ? QtcDirection.Receive : QtcDirection.Send;
}
}
/// How many logged contacts are still worth reporting to this station: the
/// whole pile rather than one series of ten. This is N1MM's
/// `QTCRecordsetCount`, which is zero for a European station on CW and SSB
/// because it only takes traffic down.
public int Available(Callsign other) =>
Role == QtcDirection.Receive ? 0 : Reportable(other).Count();
/// The three numbers the entry window shows. `LeftWith` is what may still
/// go with this station: what is free of its ten, and no more than the
/// contacts there are to report when we are the one reporting.
public QtcCounts CountsFor(Callsign other)
{
int exchanged = ExchangedWith(other);
int free = Math.Max(0, WaeQtc.MostPerStation - exchanged);
int available = Available(other);
int left = Role == QtcDirection.Send ? Math.Min(free, available) : free;
return new QtcCounts(Role, exchanged, left, available);
}
/// 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 unless
/// the operator asks for a shorter series, and fewer when the station has
/// already had some. A contact marked as a dupe is not offered, which is
/// what N1MM's `QTCRecordsetCount` leaves out.
public IReadOnlyList<WaeQtc> ToSend(Callsign other, int mostPerSeries = WaeQtc.MostPerSeries)
{
int room = Math.Min(Math.Clamp(mostPerSeries, 1, WaeQtc.MostPerSeries), Remaining(other));
if (room <= 0)
{
return [];
}
List<Qso> worth = [.. Reportable(other).OrderBy(q => q.TimestampUtc).Take(room)];
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. A series
/// number on its own means ten lines, which is what N1MM assumes: "If he
/// only types the batch number without the forward slash and count, the
/// program will assume the count is 10."
public static (int Series, int Count)? ReadHeader(string text)
{
string[] parts = text.Trim().ToUpperInvariant().Replace("QTC", "").Trim()
.Split(['/', '-', ' ', '\\'], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0 || !int.TryParse(parts[0], out int series) || series <= 0)
{
return null;
}
if (parts.Length == 1)
{
return (series, WaeQtc.MostPerSeries);
}
return int.TryParse(parts[1], out int count) && count is > 0 and <= WaeQtc.MostPerSeries
? (series, count)
: 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
/// typed part is missing. A colon is dropped whatever the length.
public static string FillTime(string typed, string above)
{
string text = typed.Trim().Replace(":", "");
string previous = above.Trim().Replace(":", "");
if (text.Length is 0 or >= 4)
{
return text;
}
int missing = 4 - text.Length;
return (previous.Length >= missing ? previous[..missing] : previous) + text;
}
private static bool IsClockTime(string text) =>
int.Parse(text[..2]) < 24 && int.Parse(text[2..]) < 60;
/// The contacts not reported to anybody yet, without the dupes and without
/// the station in front of us. The caller puts them in order. What N1MM's
/// `QTCRecordsetCount` leaves out is the dupes.
private IEnumerable<Qso> Reportable(Callsign other)
{
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);
return position.Log.Qsos
.Where(q => !WaeQtc.IsQtc(q))
.Where(q => q.ContactType.Length == 0)
.Where(q => !q.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase))
.Where(q => !reported.Contains(ReportedKey(q.Call.Text, q.SentNumber)));
}
private static string ReportedKey(string call, int number) => $"{call}|{number}";
private string ContinentOf(Callsign call) =>
position.Session.Countries?.Find(call)?.Continent ?? "";
}