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
+22
View File
@@ -29,6 +29,28 @@ public static class ExchangeSlots
_ => 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,
/// or an empty string when the file holds nothing for it.
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
/// empty.
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)
: "",
"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(),
"SENTRSTCUT" => Cut(session.DefaultReport()),
"GRID" => session.Me.GridSquare,
@@ -111,15 +115,46 @@ public static class MessageExpander
"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),
// 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 other station's screen
"ENTER" => "\r",
// the same with a line feed behind it, which is what N1MM's own
// RTTY message defaults are written with
"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,
/// which is what N1MM sends for both `{CALL}` and `!`.
private static string TheirCall(OperatingPosition session) =>
+17
View File
@@ -90,6 +90,17 @@ public sealed record MessagePlan(
MessageCommand.SendOnOtherRadio,
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
{
"WIPE" => new MessageAction(MessageCommand.Wipe),
@@ -113,6 +124,12 @@ public sealed record MessagePlan(
"CLRSTACK" => new MessageAction(MessageCommand.ClearStack),
"TX" => new MessageAction(MessageCommand.StartTransmit),
"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,
};
}
+19
View File
@@ -261,6 +261,25 @@ public sealed class OperatingPosition
{
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();
Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim())));
// the number went with the contact, so the next one starts again from