A function key message was read as text and nothing else, so a message file
written for N1MM lost half of what it said: {WIPE}, {LOG}, {RUN} and the rest
expanded to nothing and the key only sent characters.
Reading a message now produces a plan rather than a string. MessagePlan.Read
walks the template once and sorts what it finds into three parts: the actions
that run before anything goes out, the text that goes on the air, and the
actions that wait until it has gone. N1MM's {END} decides which side an action
falls on, and text after {END} is dropped, because the message is over by then.
So TU {LOG}{END}{WIPE} logs the contact, sends TU, and clears the boxes when the
key has finished sending.
The actions that are carried out: {WIPE}, {LOG}, {RUN}, {S&P}, {SPACE},
{SPOTME}, {STOPTX}, {FREQUP}, {FREQDN}, {PGUP}, {PGDN}, {JUMPRX}, {JUMPRXTX},
{CTRLF1} to {CTRLF12} for sending on the other radio, {TELNET sh/dx} for a
command to the cluster node, and {OTRSP TX2} for one to the SO2R box. The last
one wanted a way to send a command straight to the box, so So2rBox grew one.
The frequency steps are two settings, with N1MM's own two numbers behind them.
Waiting on the keyer is the part worth reading twice. What follows {END} runs
when the keyer says the message has gone out, which is the only honest moment:
timing it from the length of the text is a guess that goes wrong exactly when
the contest is busy. A keyer that does not report completion is taken at its
word as soon as the text is handed over, and one that goes quiet is given two
minutes before the actions run anyway, because never running them is worse.
Running it against a fake cwdaemon and a fake node showed the message text on
the keyer, the telnet command at the node, RUN lighting up before the message
and the boxes clearing after it. That is also what showed the entry window was
not redrawing between the actions and the message.
What is still read and passed over is written down: the CAT, audio, rotator,
stereo and call-stacking families, the digital TNC macros, and the CW keyer
characters < > ~ ] [ + = , which belong to the keyer rather than to the
expander. {EXCHSENT} waits on ESM, which is next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
8.3 KiB
C#
187 lines
8.3 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using Nonemm.Contests;
|
|
using Nonemm.Core;
|
|
using Nonemm.Core.Calls;
|
|
|
|
namespace Nonemm.Session;
|
|
|
|
/// Fills in a function key message. The macro names and what they stand for are
|
|
/// N1MM's, from its published function-key documentation, so a message file
|
|
/// written for either program says the same thing.
|
|
///
|
|
/// Only the text macros are here. N1MM also has action macros — `{WIPE}`,
|
|
/// `{LOG}`, `{RUN}`, the CAT and SO2R families — which run a program command
|
|
/// rather than standing for text; they expand to nothing, so a message that
|
|
/// holds one still sends the right characters.
|
|
public static class MessageExpander
|
|
{
|
|
/// Cut numbers: a contest operator sends T for zero and N for nine because
|
|
/// they are shorter.
|
|
private const string CutDigits = "T12345678N";
|
|
|
|
/// `other` is the radio the operator is not on, for the macros that pass a
|
|
/// station to the other band. Null when the station has one radio, and then
|
|
/// those macros stand for nothing.
|
|
public static string Expand(string template, RadioPosition session, RadioPosition? other = null)
|
|
{
|
|
StringBuilder text = new();
|
|
int at = 0;
|
|
while (at < template.Length)
|
|
{
|
|
char c = template[at];
|
|
if (c == '{')
|
|
{
|
|
int close = template.IndexOf('}', at + 1);
|
|
if (close < 0)
|
|
{
|
|
text.Append(template[at..]);
|
|
break;
|
|
}
|
|
text.Append(TextMacro(template[(at + 1)..close], session, other) ?? "");
|
|
at = close + 1;
|
|
continue;
|
|
}
|
|
// the single-character macros, which take no braces
|
|
if (c is '*' or '!' or '#')
|
|
{
|
|
text.Append(TextMacro(c, session));
|
|
at++;
|
|
continue;
|
|
}
|
|
text.Append(c);
|
|
at++;
|
|
}
|
|
return text.ToString();
|
|
}
|
|
|
|
/// The macros that need no braces: `*`, `!` and `#`.
|
|
public static string TextMacro(char macro, RadioPosition session) => macro switch
|
|
{
|
|
'*' => session.Me.Callsign,
|
|
'!' => TheirCall(session),
|
|
_ => SerialNumber(session),
|
|
};
|
|
|
|
/// The number for this contact, or the one just logged when nothing is
|
|
/// typed in the callsign box, so a repeat after logging sends the same
|
|
/// number again.
|
|
private static string SerialNumber(RadioPosition session) =>
|
|
session.Entry.Call.Trim().Length == 0 && LastLogged(session) is { } last
|
|
? last.SentNumber.ToString(CultureInfo.InvariantCulture)
|
|
: session.SentNumber.ToString(CultureInfo.InvariantCulture);
|
|
|
|
/// What the macro stands for, or null when it is not a text macro — an
|
|
/// action macro, or a name this program does not know.
|
|
public static string? TextMacro(string name, RadioPosition session, RadioPosition? other) =>
|
|
name.ToUpperInvariant() switch
|
|
{
|
|
"MYCALL" => session.Me.Callsign,
|
|
"CALL" => TheirCall(session),
|
|
"LASTCALL" => LastLogged(session)?.Call.Text ?? "",
|
|
"PREVNR" => LastLogged(session) is { } last
|
|
? last.SentNumber.ToString("000", CultureInfo.InvariantCulture)
|
|
: "",
|
|
"EXCH" => session.Instance.SentExchange,
|
|
"SENTRST" => session.DefaultReport(),
|
|
"SENTRSTCUT" => Cut(session.DefaultReport()),
|
|
"GRID" => session.Me.GridSquare,
|
|
"GRIDSQUARE" => Theirs(session, ExchangeSlot.GridSquare, known => known.GridSquare),
|
|
"GRIDBEARING" => Bearing(session, reverse: false),
|
|
"REVGRIDBEARING" => Bearing(session, reverse: true),
|
|
// N1MM's own documentation spells this one KMGRIGDISTANCE; the
|
|
// program answers to KMGRIDDISTANCE, so that is what is read here
|
|
"KMGRIDDISTANCE" => Distance(session),
|
|
"NAME" => Name(session),
|
|
"NAMEANDSPACE" => Name(session) is { Length: > 0 } theirName ? theirName + " " : "",
|
|
"CHNAME" => session.Session.History.Find(session.Entry.Call)?.Name ?? "",
|
|
"FREQ" => FrequencyText(session, session.Frequency, round: false),
|
|
"FREQROUND" => FrequencyText(session, session.Frequency, round: true),
|
|
"OTHERFREQ" => other is null ? "" : FrequencyText(session, other.Frequency, round: false),
|
|
"OTHERFREQROUND" => other is null ? "" : FrequencyText(session, other.Frequency, round: true),
|
|
"OTHERMHZ" => other is null ? "" : Megahertz(session, other.Frequency),
|
|
"OTHERBAND" => other is null ? "" : Bands.ForFrequency(other.Frequency)?.Name ?? "",
|
|
"LRMHZ" => Megahertz(session, Radio(session, other, 1)),
|
|
"RRMHZ" => Megahertz(session, Radio(session, other, 2)),
|
|
"TIMESTAMP" => DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
|
"TIME2" => DateTime.UtcNow.ToString("HHmm", CultureInfo.InvariantCulture),
|
|
_ => null,
|
|
};
|
|
|
|
/// The call being worked, or the last one logged when the box is empty,
|
|
/// which is what N1MM sends for both `{CALL}` and `!`.
|
|
private static string TheirCall(RadioPosition session) =>
|
|
session.Entry.Call.Trim() is { Length: > 0 } typed ? typed : LastLogged(session)?.Call.Text ?? "";
|
|
|
|
private static Qso? LastLogged(RadioPosition session) =>
|
|
session.Log.Qsos.Count > 0 ? session.Log.Qsos[^1] : null;
|
|
|
|
private static string Name(RadioPosition session) =>
|
|
Theirs(session, ExchangeSlot.Name, known => known.Name);
|
|
|
|
/// What the operator has typed in that exchange box, or what the call
|
|
/// history file says when the contest has no such box or it is empty.
|
|
private static string Theirs(
|
|
RadioPosition session,
|
|
ExchangeSlot slot,
|
|
Func<CallHistoryEntry, string> fromHistory)
|
|
{
|
|
string typed = session.Entry.ValueOf(slot).Trim();
|
|
if (typed.Length > 0)
|
|
{
|
|
return typed;
|
|
}
|
|
CallHistoryEntry? known = session.Session.History.Find(session.Entry.Call);
|
|
return known is null ? "" : fromHistory(known);
|
|
}
|
|
|
|
/// Kilohertz to one decimal, and on CW the decimal point is sent as R.
|
|
private static string FrequencyText(RadioPosition session, Frequency frequency, bool round)
|
|
{
|
|
if (round)
|
|
{
|
|
return Math.Round(frequency.Kilohertz).ToString("0", CultureInfo.InvariantCulture);
|
|
}
|
|
return CwDecimal(session, frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
/// The band in megahertz, as N1MM sends it: 14, or 3R5 on CW.
|
|
private static string Megahertz(RadioPosition session, Frequency frequency) =>
|
|
Bands.ForFrequency(frequency) is { } band
|
|
? CwDecimal(session, band.MegahertzLabel.ToString("0.###", CultureInfo.InvariantCulture))
|
|
: "";
|
|
|
|
private static Frequency Radio(RadioPosition session, RadioPosition? other, int number) =>
|
|
session.RadioNumber == number ? session.Frequency
|
|
: other?.RadioNumber == number ? other.Frequency
|
|
: Frequency.Zero;
|
|
|
|
private static string CwDecimal(RadioPosition session, string text) =>
|
|
session.Mode.Category == ModeCategory.Cw ? text.Replace('.', 'R') : text;
|
|
|
|
private static string Bearing(RadioPosition session, bool reverse)
|
|
{
|
|
if (!Grids(session, out GridSquare mine, out GridSquare theirs))
|
|
{
|
|
return "";
|
|
}
|
|
double bearing = reverse ? theirs.BearingTo(mine) : mine.BearingTo(theirs);
|
|
return Math.Round(bearing).ToString("0", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private static string Distance(RadioPosition session) =>
|
|
Grids(session, out GridSquare mine, out GridSquare theirs)
|
|
? Math.Round(mine.DistanceTo(theirs)).ToString("0", CultureInfo.InvariantCulture)
|
|
: "";
|
|
|
|
private static bool Grids(RadioPosition session, out GridSquare mine, out GridSquare theirs)
|
|
{
|
|
theirs = default;
|
|
return GridSquare.TryParse(session.Me.GridSquare, out mine)
|
|
&& GridSquare.TryParse(Theirs(session, ExchangeSlot.GridSquare, known => known.GridSquare), out theirs);
|
|
}
|
|
|
|
private static string Cut(string digits) =>
|
|
new(digits.Select(d => char.IsAsciiDigit(d) ? CutDigits[d - '0'] : d).ToArray());
|
|
}
|