Fill in the macros from N1MM's function key page

Going down N1MM's published list, the ones a contest operator would miss:

| | |
|---|---|
| `{LASTEXCH}` | what the last station sent, built from the boxes the contest asks for |
| `{ROVERQTH}` | where a rover is now |
| `{TIME}`, `{DATE}`, `{TIMEGMT}`, `{DATEGMT}` | the clock and the calendar |
| `{ESC}`, `{CTRL-A}` to `{CTRL-Z}` | the control characters a TNC reads as commands |
| `{CTRLHOME}` | the cursor back in the callsign box |
| `{F1}` to `{F12}` | another of this radio's keys, where `{CTRLF1}` is the other radio's |
| `{CQ}` | makes any key a CQ key: it starts running and carries alternating CQ on |
| `{DIGQTCS}`, `{DIGQTCR}` | opens the WAE QTC window the way asked for |
| `{CLEARRIT}` | the receive offset back to zero, which is rigctld's `J 0` |
| `{FORCELOG}` | logs what is in the boxes, holes and all |

`{FORCELOG}` is the one that gives something up. `LogContact` refuses an entry
that is short or holds a value the contest will not take, so that a caller who
has not checked cannot log nonsense; `LogContactAnyway` is the way past it,
because a contact in the log with a hole in it can be filled in afterwards and
one that was never logged cannot. It still needs a callsign, and the status
line says the row will need fixing before the log goes in.

A key that names itself would send for ever, so a key sending another key
ignores the macro a second time down.

What is still passed over is written down in the README and unfinished.md.
Advanced voicing is the one that matters: N1MM says a callsign or a serial by
playing a recording per letter out of `LettersFiles`, which is why `!` and `#`
say nothing on phone here and why F5 is empty in N1MM's own SSB file too. The
three packet BBS date formats are passed over because there is nothing here to
check a guess at them against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
This commit is contained in:
2026-09-11 08:24:34 +00:00
co-authored by Claude Opus 5
parent 31ad6fb192
commit b5043506ae
16 changed files with 358 additions and 14 deletions
+21 -6
View File
@@ -156,7 +156,7 @@ macros that are filled in:
| `*`, `{MYCALL}` | your callsign | | `*`, `{MYCALL}` | your callsign |
| `!`, `{CALL}` | the call being worked, or the last one logged when the box is empty | | `!`, `{CALL}` | the call being worked, or the last one logged when the box is empty |
| `#` | the serial number for this contact, or the last one when the box is empty | | `#` | the serial number for this contact, or the last one when the box is empty |
| `{LASTCALL}`, `{PREVNR}` | the call and the serial of the contact just logged | | `{LASTCALL}`, `{PREVNR}`, `{LASTEXCH}` | the call, the serial and the exchange of the contact just logged |
| `{EXCH}` | the sent exchange from the contest setup | | `{EXCH}` | the sent exchange from the contest setup |
| `{SENTRST}`, `{SENTRSTCUT}` | the report you send, plain or in cut numbers | | `{SENTRST}`, `{SENTRSTCUT}` | the report you send, plain or in cut numbers |
| `{NAME}`, `{NAMEANDSPACE}`, `{CHNAME}` | the other operator's name, from the entry window or the call history file | | `{NAME}`, `{NAMEANDSPACE}`, `{CHNAME}` | the other operator's name, from the entry window or the call history file |
@@ -164,13 +164,20 @@ macros that are filled in:
| `{GRIDBEARING}`, `{REVGRIDBEARING}`, `{KMGRIDDISTANCE}` | bearing and distance between the two | | `{GRIDBEARING}`, `{REVGRIDBEARING}`, `{KMGRIDDISTANCE}` | bearing and distance between the two |
| `{FREQ}`, `{FREQROUND}` | this radio's frequency in kilohertz, with `R` for the decimal point on CW | | `{FREQ}`, `{FREQROUND}` | this radio's frequency in kilohertz, with `R` for the decimal point on CW |
| `{OTHERFREQ}`, `{OTHERFREQROUND}`, `{OTHERMHZ}`, `{OTHERBAND}`, `{LRMHZ}`, `{RRMHZ}` | the other radio, for passing a station | | `{OTHERFREQ}`, `{OTHERFREQROUND}`, `{OTHERMHZ}`, `{OTHERBAND}`, `{LRMHZ}`, `{RRMHZ}` | the other radio, for passing a station |
| `{TIMESTAMP}`, `{TIME2}` | the time now | | `{TIMESTAMP}`, `{TIME2}` | the time now, as `2026-09-11 07:45` and as `0745` |
| `{TIME}`, `{DATE}`, `{TIMEGMT}`, `{DATEGMT}` | the clock and the calendar the way this machine writes them, local or UTC |
| `{ROVERQTH}` | where a rover is now, from the station data |
| `{ESC}`, `{CTRL-A}` to `{CTRL-Z}` | the control characters a TNC reads as commands |
| `{WAVDIR}` | the folder the voice keyer plays from, which is named after whoever is at the microphone | | `{WAVDIR}` | the folder the voice keyer plays from, which is named after whoever is at the microphone |
The action macros run rather than standing for text: `{WIPE}`, `{LOG}`, The action macros run rather than standing for text: `{WIPE}`, `{LOG}`,
`{RUN}`, `{S&P}`, `{SPACE}`, `{SPOTME}`, `{STOPTX}`, `{FREQUP}`, `{FREQDN}`, `{RUN}`, `{S&P}`, `{SPACE}`, `{SPOTME}`, `{STOPTX}`, `{FREQUP}`, `{FREQDN}`,
`{PGUP}`, `{PGDN}`, `{JUMPRX}`, `{JUMPRXTX}`, `{STACKANOTHER}`, `{SOCALLSTACK}`, `{PGUP}`, `{PGDN}`, `{JUMPRX}`, `{JUMPRXTX}`, `{STACKANOTHER}`, `{SOCALLSTACK}`,
`{LOGTHENPOP}`, `{LOGTHENNEXT}`, `{CLRSTACK}`, `{CTRLF1}` to `{CTRLF12}` to send `{LOGTHENPOP}`, `{LOGTHENNEXT}`, `{CLRSTACK}`, `{CTRLHOME}` to put the cursor
back in the callsign box, `{CLEARRIT}` to put the receive offset back to zero,
`{FORCELOG}` to log a contact the checks refuse, `{CQ}` to make a key a CQ key,
`{DIGQTCS}` and `{DIGQTCR}` to open the WAE QTC window, `{F1}` to `{F12}` to
send another of this radio's keys, `{CTRLF1}` to `{CTRLF12}` to send
on the other radio, `{TELNET sh/dx}` to put a command on the cluster, and on the other radio, `{TELNET sh/dx}` to put a command on the cluster, and
`{OTRSP TX2}` to send one to the SO2R box. An action runs before the message `{OTRSP TX2}` to send one to the SO2R box. An action runs before the message
goes out, unless it stands after `{END}`, and then it runs once the message has goes out, unless it stands after `{END}`, and then it runs once the message has
@@ -182,9 +189,17 @@ How far `{FREQUP}` and the rest move the radio is `FrequencyStepHertz` and
`PageStepHertz` in `settings.json`, 100 Hz and 1 kHz out of the box; N1MM asks `PageStepHertz` in `settings.json`, 100 Hz and 1 kHz out of the box; N1MM asks
for the same two numbers in its Configurer. for the same two numbers in its Configurer.
The rest of N1MM's action macros — the CAT families, audio, rotators, stereo, `{CQ}` is for a station whose CQ is not on F1: the key it stands in starts
the digital TNC macros, `{SETWAVDIR}` — are read and passed over, so a message running and carries alternating CQ on, which F1 does by itself. `{FORCELOG}`
that holds one still sends the right characters. logs whatever is in the boxes, holes and all — it still needs a callsign, and
the status line says the row will need fixing before the log goes in.
The rest of N1MM's macros — the CAT families, audio, rotators, stereo,
`{SETWAVDIR}`, `{SwapContests}`, `{VARYMSG1}` and `{VARYMSG2}`, `{COMMENT}`,
`{FORCELOGNOTE}`, `{CONDJUMP}`, `{QSYCQ}`, `{CLUSTER}` and the two needed-QSO
macros — are read and passed over, so a message that holds one still sends the right
characters. So are `{TIME1}`, `{DATE1}` and `{DAYTIME}`, which are packet BBS
date formats this has no way to check.
### The voice keyer ### The voice keyer
+16 -4
View File
@@ -591,10 +591,22 @@ when a download brings back something unreadable.
**Not every action macro is acted on.** The text macros and the action macros **Not every action macro is acted on.** The text macros and the action macros
that this program can carry out are listed in the README. What is read and that this program can carry out are listed in the README. What is read and
passed over: the `{CAT…}` and radio-hex families, the audio and rotator macros, passed over: the `{CAT…}` and radio-hex families, the audio and rotator macros,
`{STEREOON}` and `{STEREOOFF}`, `{CONDJUMP}`, `{STEREOON}` and `{STEREOOFF}`, `{CONDJUMP}`, `{QSYCQ}`, `{FORCELOGNOTE}`,
`{QSYCQ}`, `{FORCELOG}`, `{SwapContests}`, the digital TNC macros (`{ENTER}`, `{COMMENT}`, `{SwapContests}`, `{VARYMSG1}` and `{VARYMSG2}`, the two
`{ESC}`, `{CTRL-A}`…), and `{SETWAVDIR}`, which moves the voice keyer's folder needed-QSO macros, and `{SETWAVDIR}`, which moves the voice keyer's folder in
in the middle of a contest. the middle of a contest.
`{TIME1}`, `{DATE1}` and `{DAYTIME}` are passed over as well. They are the
Nordlink-TF, WA8DED and TAPR packet date formats, and there is nothing here to
check a guess at them against; the ordinary `{TIME}`, `{DATE}`, `{TIMEGMT}`,
`{DATEGMT}`, `{TIMESTAMP}` and `{TIME2}` are all filled in.
**Advanced voicing is not written.** N1MM says a callsign or a serial number by
playing a recording per letter and per number out of `LettersFiles` — `A.wav`
to `Z.wav`, `0.wav` to `19.wav`, the tens, `HUNDRED.wav`, `THOUSAND.wav`,
`STROKE.wav`, `QUERY.wav` and `POINT.wav`. Without it `!`, `#` and `@` in a
phone message have nothing to say, which is why F5 is empty in N1MM's own
default SSB file and in this program's.
A message holding any of them still sends the right characters. A message holding any of them still sends the right characters.
The CW keyer macros are not read either: `<` and `>` for speed, `~` for a half The CW keyer macros are not read either: `<` and `>` for speed, `~` for a half
+1
View File
@@ -715,6 +715,7 @@ public sealed record StoredStation
Name = Name, Name = Name,
Power = Power, Power = Power,
Club = Club, Club = Club,
RoverQth = RoverQth,
Check = Check, Check = Check,
Precedence = Precedence, Precedence = Precedence,
Latitude = Latitude, Latitude = Latitude,
@@ -80,6 +80,11 @@ public sealed partial class EntryWindow
Refresh(); Refresh();
} }
/// True while a key is sending another key's message, so a message that
/// names its own key — or two that name each other — stops there instead
/// of going round for ever.
private bool sendingAnotherKey;
private void SendMessage(int index) => _ = SendKeyAsync(index); private void SendMessage(int index) => _ = SendKeyAsync(index);
/// Sends one function key's message. The task finishes when the text has /// Sends one function key's message. The task finishes when the text has
@@ -359,6 +364,45 @@ public sealed partial class EntryWindow
_ = session.Digital?.ReturnToReceiveAsync(); _ = session.Digital?.ReturnToReceiveAsync();
} }
break; break;
case MessageCommand.CursorToCall:
boxes[0].Focus();
break;
case MessageCommand.SendKey:
if (!sendingAnotherKey
&& int.TryParse(action.Argument, out int key)
&& key is >= 1 and <= MessageFile.KeyCount)
{
sendingAnotherKey = true;
try
{
SendMessage(key - 1);
}
finally
{
sendingAnotherKey = false;
}
}
break;
case MessageCommand.CallCq:
// what F1 does by itself: a CQ puts the station in run mode and
// carries alternating CQ on to the other radio
Logging.IsRunning = true;
RememberCqFrequency();
session.Alternating?.CarryOn();
CqSent?.Invoke(this, EventArgs.Empty);
break;
case MessageCommand.SendQtc:
_ = OpenQtcWindow(QtcDirection.Send);
break;
case MessageCommand.ReceiveQtc:
_ = OpenQtcWindow(QtcDirection.Receive);
break;
case MessageCommand.ClearRit:
_ = session.Radio?.ClearRitAsync();
break;
case MessageCommand.LogAnyway:
LogContactAnyway();
break;
} }
} }
+38 -1
View File
@@ -543,6 +543,30 @@ public sealed partial class EntryWindow : Window
boxes[0].Focus(); boxes[0].Focus();
} }
/// N1MM's `{FORCELOG}`: the contact goes in the log with whatever is in the
/// boxes, holes and all. The status line says it was forced, because the
/// row will need fixing before the log is sent in.
private void LogContactAnyway()
{
if (Logging is null)
{
return;
}
try
{
Qso logged = Logging.LogContactAnyway();
Status($"logged {logged.Call} as it stands — check it before sending the log in");
}
catch (Exception e) when (e is InvalidOperationException or IOException)
{
Status($"could not log the contact: {e.Message}");
return;
}
ResetEsm();
SyncBoxes();
boxes[0].Focus();
}
/// True when everything typed can go in the log. A value the contest will /// True when everything typed can go in the log. A value the contest will
/// not take puts the reason in the status line and the cursor in its box, /// not take puts the reason in the status line and the cursor in its box,
/// which is where the operator has to fix it. /// which is where the operator has to fix it.
@@ -1136,7 +1160,11 @@ public sealed partial class EntryWindow : Window
/// WAE QTC traffic, on Ctrl+Z as in N1MM. The contact is logged first if it /// 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 /// is still in the boxes: the traffic belongs to a station that has been
/// worked. /// worked.
private async Task OpenQtcWindow() ///
/// `wanted` is the way N1MM's `{DIGQTCS}` and `{DIGQTCR}` ask for, which
/// is only allowed where the contest and the station leave it open. Ctrl+Z
/// passes null and the rules decide.
private async Task OpenQtcWindow(QtcDirection? wanted = null)
{ {
if (Logging is null) if (Logging is null)
{ {
@@ -1165,6 +1193,15 @@ public sealed partial class EntryWindow : Window
Status(why); Status(why);
return; return;
} }
if (wanted is { } asked)
{
if (direction is not QtcDirection.Either && direction != asked)
{
Status($"traffic with {station.Text} can only go the other way");
return;
}
direction = asked;
}
if (direction is QtcDirection.Send && traffic.ToSend(station).Count == 0) if (direction is QtcDirection.Send && traffic.ToSend(station).Count == 0)
{ {
Status("nothing left to report to " + station.Text); Status("nothing left to report to " + station.Text);
+3
View File
@@ -31,6 +31,9 @@ public sealed record StationInfo
public string Club { get; init; } = ""; public string Club { get; init; } = "";
/// Where a rover is at the moment, which is what `{ROVERQTH}` sends.
public string RoverQth { get; init; } = "";
/// The last two digits of the year first licensed, which Sweepstakes calls /// The last two digits of the year first licensed, which Sweepstakes calls
/// the check. /// the check.
public int Check { get; init; } public int Check { get; init; }
+4
View File
@@ -27,4 +27,8 @@ public interface Radio : IDisposable
/// Null turns split off. Not every radio can do this; one that cannot says /// Null turns split off. Not every radio can do this; one that cannot says
/// so and the logger carries on. /// so and the logger carries on.
Task SetSplitAsync(Frequency? transmitFrequency, CancellationToken cancellation = default); Task SetSplitAsync(Frequency? transmitFrequency, CancellationToken cancellation = default);
/// Puts the receive offset back to zero, which is what an operator does
/// after working a station he had to tune off frequency for.
Task ClearRitAsync(CancellationToken cancellation = default);
} }
+5
View File
@@ -67,6 +67,11 @@ public sealed class RigctldRadio : Radio
await SendAsync("S 1 VFOB", cancellation).ConfigureAwait(false); await SendAsync("S 1 VFOB", cancellation).ConfigureAwait(false);
} }
/// `J` is rigctld's set_rit, in hertz. Zero is the offset turned off,
/// which is what the radio's own RIT clear button does.
public async Task ClearRitAsync(CancellationToken cancellation = default) =>
await SendAsync("J 0", cancellation).ConfigureAwait(false);
public void Dispose() public void Dispose()
{ {
stopping.Cancel(); stopping.Cancel();
+22
View File
@@ -29,6 +29,28 @@ public static class ExchangeSlots
_ => qso, _ => qso,
}; };
/// What a logged contact holds in one exchange box, which is the other way
/// round from `Apply`.
public static string ValueOf(Qso qso, ExchangeSlot slot) => slot switch
{
ExchangeSlot.SentReport => qso.SentReport,
ExchangeSlot.SentNumber => Digits(qso.SentNumber),
ExchangeSlot.ReceivedReport => qso.ReceivedReport,
ExchangeSlot.SerialNumber => Digits(qso.ReceivedNumber),
ExchangeSlot.Zone => Digits(qso.Zone),
ExchangeSlot.Section => qso.Section,
ExchangeSlot.Check => Digits(qso.Check),
ExchangeSlot.Precedence => qso.Precedence,
ExchangeSlot.Exchange1 => qso.Exchange1,
ExchangeSlot.MiscText => qso.MiscText,
ExchangeSlot.Name => qso.Name,
ExchangeSlot.Qth => qso.Qth,
ExchangeSlot.GridSquare => qso.GridSquare,
ExchangeSlot.Power => qso.Power,
ExchangeSlot.Comment => qso.Comment,
_ => "",
};
/// What the call history file says this station sends in one exchange box, /// What the call history file says this station sends in one exchange box,
/// or an empty string when the file holds nothing for it. /// or an empty string when the file holds nothing for it.
public static string ValueFrom(CallHistoryEntry known, ExchangeField field) => public static string ValueFrom(CallHistoryEntry known, ExchangeField field) =>
+24
View File
@@ -73,4 +73,28 @@ public enum MessageCommand
/// N1MM's `{RX}`: the engine goes back to receive once the buffer is /// N1MM's `{RX}`: the engine goes back to receive once the buffer is
/// empty. /// empty.
ReturnToReceive, ReturnToReceive,
/// Puts the cursor back in the callsign box, wherever it was. N1MM's
/// `{CTRLHOME}`.
CursorToCall,
/// Sends one of this radio's own function key messages. The argument is
/// the key number, so `{F9}` sends F9.
SendKey,
/// Says this key is a CQ, for a station whose CQ is not on F1: it starts
/// running and carries alternating CQ on, which F1 does by itself.
CallCq,
/// Opens the WAE QTC window to read traffic out, or to take it down.
SendQtc,
ReceiveQtc,
/// Puts the radio's receive offset back to zero.
ClearRit,
/// Logs the contact even though the exchange is short or a box holds
/// something the contest will not take. N1MM's `{FORCELOG}`.
LogAnyway,
} }
+36 -1
View File
@@ -89,6 +89,10 @@ public static class MessageExpander
? last.SentNumber.ToString("000", CultureInfo.InvariantCulture) ? last.SentNumber.ToString("000", CultureInfo.InvariantCulture)
: "", : "",
"EXCH" => session.Instance.SentExchange, "EXCH" => session.Instance.SentExchange,
// what the last station sent, put together out of the boxes this
// contest asks for, in the order it asks for them
"LASTEXCH" => LastExchange(session),
"ROVERQTH" => session.Me.RoverQth,
"SENTRST" => session.DefaultReport(), "SENTRST" => session.DefaultReport(),
"SENTRSTCUT" => Cut(session.DefaultReport()), "SENTRSTCUT" => Cut(session.DefaultReport()),
"GRID" => session.Me.GridSquare, "GRID" => session.Me.GridSquare,
@@ -111,15 +115,46 @@ public static class MessageExpander
"RRMHZ" => Megahertz(session, Radio(session, other, 2)), "RRMHZ" => Megahertz(session, Radio(session, other, 2)),
"TIMESTAMP" => DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture), "TIMESTAMP" => DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
"TIME2" => DateTime.UtcNow.ToString("HHmm", CultureInfo.InvariantCulture), "TIME2" => DateTime.UtcNow.ToString("HHmm", CultureInfo.InvariantCulture),
// the local clock, in the shapes this machine writes them. N1MM
// calls these the Windows formats, which is the same thing there
"TIME" => DateTime.Now.ToString("t", CultureInfo.CurrentCulture),
"DATE" => DateTime.Now.ToString("d", CultureInfo.CurrentCulture),
"TIMEGMT" => DateTime.UtcNow.ToString("t", CultureInfo.CurrentCulture),
"DATEGMT" => DateTime.UtcNow.ToString("g", CultureInfo.CurrentCulture),
// the digital window's carriage return, which starts a new line on // the digital window's carriage return, which starts a new line on
// the other station's screen // the other station's screen
"ENTER" => "\r", "ENTER" => "\r",
// the same with a line feed behind it, which is what N1MM's own // the same with a line feed behind it, which is what N1MM's own
// RTTY message defaults are written with // RTTY message defaults are written with
"ENTERLF" => "\r\n", "ENTERLF" => "\r\n",
_ => null, "ESC" => "\u001b",
_ => Control(name.ToUpperInvariant()),
}; };
/// N1MM's `{CTRL-A}` to `{CTRL-Z}`, which are the control characters a TNC
/// reads as commands. They are characters, so they are text here.
private static string? Control(string name) =>
name.Length == 6 && name.StartsWith("CTRL-", StringComparison.Ordinal)
&& name[5] is >= 'A' and <= 'Z'
? ((char)(name[5] - 'A' + 1)).ToString()
: null;
/// The exchange of the last contact logged, which is every received box
/// the contest asks for with a space between them. N1MM's `{LASTEXCH}`.
private static string LastExchange(OperatingPosition session)
{
if (LastLogged(session) is not { } last)
{
return "";
}
IEnumerable<string> values = session.Contest
.ExchangeFieldsFor(session.Me)
.Where(field => field.Slot is not (ExchangeSlot.SentReport or ExchangeSlot.SentNumber))
.Select(field => ExchangeSlots.ValueOf(last, field.Slot).Trim())
.Where(value => value.Length > 0);
return string.Join(' ', values);
}
/// The call being worked, or the last one logged when the box is empty, /// The call being worked, or the last one logged when the box is empty,
/// which is what N1MM sends for both `{CALL}` and `!`. /// which is what N1MM sends for both `{CALL}` and `!`.
private static string TheirCall(OperatingPosition session) => private static string TheirCall(OperatingPosition session) =>
+17
View File
@@ -90,6 +90,17 @@ public sealed record MessagePlan(
MessageCommand.SendOnOtherRadio, MessageCommand.SendOnOtherRadio,
key.ToString(CultureInfo.InvariantCulture)); key.ToString(CultureInfo.InvariantCulture));
} }
// `{F9}` sends this radio's F9, where `{CTRLF9}` sends the other
// radio's, so the check for this one comes after that one
if (upper.Length is 2 or 3
&& upper[0] == 'F'
&& int.TryParse(upper[1..], NumberStyles.None, CultureInfo.InvariantCulture, out int own)
&& own is >= 1 and <= 12)
{
return new MessageAction(
MessageCommand.SendKey,
own.ToString(CultureInfo.InvariantCulture));
}
return upper switch return upper switch
{ {
"WIPE" => new MessageAction(MessageCommand.Wipe), "WIPE" => new MessageAction(MessageCommand.Wipe),
@@ -113,6 +124,12 @@ public sealed record MessagePlan(
"CLRSTACK" => new MessageAction(MessageCommand.ClearStack), "CLRSTACK" => new MessageAction(MessageCommand.ClearStack),
"TX" => new MessageAction(MessageCommand.StartTransmit), "TX" => new MessageAction(MessageCommand.StartTransmit),
"RX" => new MessageAction(MessageCommand.ReturnToReceive), "RX" => new MessageAction(MessageCommand.ReturnToReceive),
"CTRLHOME" => new MessageAction(MessageCommand.CursorToCall),
"CQ" => new MessageAction(MessageCommand.CallCq),
"DIGQTCS" => new MessageAction(MessageCommand.SendQtc),
"DIGQTCR" => new MessageAction(MessageCommand.ReceiveQtc),
"CLEARRIT" => new MessageAction(MessageCommand.ClearRit),
"FORCELOG" => new MessageAction(MessageCommand.LogAnyway),
_ => null, _ => null,
}; };
} }
+19
View File
@@ -261,6 +261,25 @@ public sealed class OperatingPosition
{ {
throw new InvalidOperationException(problem.Message); throw new InvalidOperationException(problem.Message);
} }
return Store();
}
/// Logs what is typed although the exchange is short or a box holds
/// something the contest will not take, which is N1MM's `{FORCELOG}`. A
/// contact in the log with a hole in it can be filled in afterwards; one
/// that was never logged cannot. The callsign is still required, because a
/// row with no callsign in it is not a contact.
public Qso LogContactAnyway()
{
if (Entry.Call.Trim().Length == 0)
{
throw new InvalidOperationException("there is no callsign to log");
}
return Store();
}
private Qso Store()
{
StampSentTime(); StampSentTime();
Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim()))); Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim())));
// the number went with the contact, so the next one starts again from // the number went with the contact, so the next one starts again from
@@ -8,7 +8,10 @@ namespace Nonemm.Session.Tests;
public class MessageExpanderTests public class MessageExpanderTests
{ {
private static OperatingPosition Session(CallHistory? history = null, int radioNumber = 1) private static OperatingPosition Session(
CallHistory? history = null,
int radioNumber = 1,
string roverQth = "")
{ {
FakeLogStore store = new(); FakeLogStore store = new();
ContestInstance instance = store.AddContest(new ContestInstance ContestInstance instance = store.AddContest(new ContestInstance
@@ -22,7 +25,14 @@ public class MessageExpanderTests
store, store,
new CqWorldWide(ModeCategory.Cw), new CqWorldWide(ModeCategory.Cw),
instance, instance,
new StationInfo { Callsign = "DL1ABC", CqZone = 14, Name = "Erik", GridSquare = "JN88" }, new StationInfo
{
Callsign = "DL1ABC",
CqZone = 14,
Name = "Erik",
GridSquare = "JN88",
RoverQth = roverQth,
},
null, null,
history), history),
radioNumber); radioNumber);
@@ -77,6 +87,39 @@ public class MessageExpanderTests
Assert.Equal("JA1XYZ JA1XYZ", MessageExpander.Expand("{CALL} {LASTCALL}", session)); Assert.Equal("JA1XYZ JA1XYZ", MessageExpander.Expand("{CALL} {LASTCALL}", session));
} }
/// N1MM's `{LASTEXCH}`: what the last station sent, which is every
/// received box the contest asks for. CQ WW asks for the report and the
/// zone.
[Fact]
public void TheLastExchangeIsWhatTheLastStationSent()
{
OperatingPosition session = Session();
session.Entry.Call = "JA1XYZ";
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
session.Entry.Set(ExchangeSlot.Zone, "25");
session.LogContact();
Assert.Equal("599 25", MessageExpander.Expand("{LASTEXCH}", session));
}
[Fact]
public void TheLastExchangeIsEmptyBeforeAnythingIsLogged() =>
Assert.Equal("", MessageExpander.Expand("{LASTEXCH}", Session()));
/// The control characters a TNC reads as commands. `{CTRL-A}` is 0x01 and
/// `{ESC}` is 0x1B.
[Fact]
public void TheTncControlCharactersAreSentAsThemselves() =>
Assert.Equal("\u0001\u001a\u001b", MessageExpander.Expand("{CTRL-A}{CTRL-Z}{ESC}", Session()));
[Fact]
public void ARoverSendsWhereItIs()
{
OperatingPosition session = Session(roverQth: "JN99");
Assert.Equal("QTH JN99", MessageExpander.Expand("QTH {ROVERQTH}", session));
}
[Fact] [Fact]
public void TheFrequencySendsRForTheDecimalPointOnCw() public void TheFrequencySendsRForTheDecimalPointOnCw()
{ {
@@ -24,6 +24,48 @@ public class MessagePlanTests
null)); null));
} }
/// `{CTRLF9}` sends the other radio's F9 and `{F9}` sends this radio's, so
/// the two are told apart by the prefix alone.
[Fact]
public void AFunctionKeyMacroSendsThatKeyOnThisRadio()
{
MessagePlan plan = MessagePlan.Read("{F9}", Session());
MessageAction action = Assert.Single(plan.Before);
Assert.Equal(MessageCommand.SendKey, action.Command);
Assert.Equal("9", action.Argument);
}
[Fact]
public void TheOtherRadiosKeyIsStillTheCtrlOne()
{
MessagePlan plan = MessagePlan.Read("{CTRLF9}", Session());
Assert.Equal(MessageCommand.SendOnOtherRadio, Assert.Single(plan.Before).Command);
}
[Fact]
public void AKeyPastTheTwelfthIsNotAFunctionKeyMacro()
{
MessagePlan plan = MessagePlan.Read("{F13}", Session());
Assert.Empty(plan.Before);
Assert.Equal("", plan.Text);
}
[Theory]
[InlineData("{CTRLHOME}", MessageCommand.CursorToCall)]
[InlineData("{CQ}", MessageCommand.CallCq)]
[InlineData("{DIGQTCS}", MessageCommand.SendQtc)]
[InlineData("{DIGQTCR}", MessageCommand.ReceiveQtc)]
public void TheMacrosThatRunSomething(string macro, MessageCommand command)
{
MessagePlan plan = MessagePlan.Read(macro, Session());
Assert.Equal(command, Assert.Single(plan.Before).Command);
Assert.Equal("", plan.Text);
}
[Fact] [Fact]
public void AMessageWithNoMacrosIsJustText() public void AMessageWithNoMacrosIsJustText()
{ {
@@ -25,6 +25,27 @@ public class OperatingPositionTests
CountryPrefix = "DL", CountryPrefix = "DL",
}; };
/// N1MM's `{FORCELOG}`: a contact the checks refuse still goes in the log,
/// because one that was never logged cannot be fixed afterwards.
[Fact]
public void AForcedContactIsLoggedWithTheExchangeShort()
{
OperatingPosition session = Session();
session.Entry.Call = "JA1XYZ";
Assert.False(session.IsEntryComplete);
Assert.Equal("JA1XYZ", session.LogContactAnyway().Call.Text);
Assert.Single(session.Log.Qsos);
}
[Fact]
public void AForcedContactStillNeedsACallsign()
{
OperatingPosition session = Session();
Assert.Throws<InvalidOperationException>(() => session.LogContactAnyway());
}
private static OperatingPosition Session(Contest? contest = null) => private static OperatingPosition Session(Contest? contest = null) =>
new(Shared(new FakeLogStore(), contest)); new(Shared(new FakeLogStore(), contest));