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:
@@ -715,6 +715,7 @@ public sealed record StoredStation
|
||||
Name = Name,
|
||||
Power = Power,
|
||||
Club = Club,
|
||||
RoverQth = RoverQth,
|
||||
Check = Check,
|
||||
Precedence = Precedence,
|
||||
Latitude = Latitude,
|
||||
|
||||
@@ -80,6 +80,11 @@ public sealed partial class EntryWindow
|
||||
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);
|
||||
|
||||
/// 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();
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -543,6 +543,30 @@ public sealed partial class EntryWindow : Window
|
||||
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
|
||||
/// not take puts the reason in the status line and the cursor in its box,
|
||||
/// 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
|
||||
/// is still in the boxes: the traffic belongs to a station that has been
|
||||
/// 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)
|
||||
{
|
||||
@@ -1165,6 +1193,15 @@ public sealed partial class EntryWindow : Window
|
||||
Status(why);
|
||||
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)
|
||||
{
|
||||
Status("nothing left to report to " + station.Text);
|
||||
|
||||
@@ -31,6 +31,9 @@ public sealed record StationInfo
|
||||
|
||||
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 check.
|
||||
public int Check { get; init; }
|
||||
|
||||
@@ -27,4 +27,8 @@ public interface Radio : IDisposable
|
||||
/// Null turns split off. Not every radio can do this; one that cannot says
|
||||
/// so and the logger carries on.
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,11 @@ public sealed class RigctldRadio : Radio
|
||||
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()
|
||||
{
|
||||
stopping.Cancel();
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user