Let Enter send the message the contact has got to

ESM is how most people run N1MM, and it was not here at all — not in the code,
not even in the list of what is missing. Config ▸ ESM turns it on and it stays
on between runs.

The decision is a table in N1MM's function-key documentation, and it is written
here as one: the state of the callsign and exchange boxes, whether the station
is running or searching, and whether the call and the exchange have gone out
already, give the function keys Enter sends and whether the contact is logged
after them. Esm.Decide is that table and nothing else, so it is tested against
every row rather than by clicking.

Searching: type a call, Enter sends your call, space moves to the exchange, and
once the exchange is filled in Enter sends yours and logs. Running: Enter calls
CQ, a call in the box makes Enter send his call and the exchange, and the next
Enter ends the contact and logs it. A dupe gets QSO B4 while running and nothing
while searching, unless dupes are worked, which is what N1MM recommends and what
this does out of the box. F1 puts a searching station into run mode.

The entry window highlights the keys Enter would send next, so what is about to
happen is on the screen rather than in the operator's head. `=` sends whatever
Enter last sent. Escape and F12 put ESM back to the start of a contact.

Send Corrected Call is in as well: copy SM3AB, send it, fix it to SM3ABC, and
the message that ends the contact goes out as "SM3ABC TU DL1ABC". That is why
N1MM's documentation says to put ! in F5 rather than {CALL}, so the default F5
message is now ! . F6 is QSO B4, which is the message ESM sends a dupe; it used
to repeat the exchange, which no part of the table asks for.

{EXCHSENT} means something again, now that there is an ESM state for it to set.

Worked end to end against a fake cwdaemon: a search-and-pounce contact logged in
four keystrokes, a run contact in three, both with the right text on the keyer
in the right order, and the corrected call in front of the last message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 07:59:11 +00:00
parent 333d3b97c2
commit 0c440ed865
13 changed files with 458 additions and 13 deletions

View File

@@ -131,6 +131,31 @@ The rest of N1MM's action macros — the CAT families, audio, rotators, stereo,
call stacking, the digital TNC macros — are read and passed over, so a message
that holds one still sends the right characters.
### ESM — Enter sends the message
**Config → ESM** turns it on, and it stays on between runs. Enter then sends
whatever the contact has got to, instead of only logging, which is how most
people run N1MM. The keys Enter would send next are highlighted, so what is
about to happen is on the screen.
Searching: type a call, Enter sends your call (F4), space moves to the exchange,
and when the exchange is filled in Enter sends yours (F2) and logs the contact.
Running: Enter calls CQ (F1); a call in the box makes Enter send his call and
your exchange (F5 and F2); once the exchange you copied is in the boxes, Enter
sends the message that ends the contact (F3) and logs it. A station already in
the log gets QSO B4 (F6) while running, and nothing at all while searching.
Pressing F1 while searching puts you into run mode, as it does in N1MM.
`=` sends whatever Enter last sent, without deciding again. Escape and F12 clear
the boxes and put ESM back to the start of a contact.
Three switches live in `settings.json`, and are N1MM's own:
`EsmSendsCallOnce` sends your call once while searching and then moves to the
exchange (N1MM's "big gun" switch), `EsmWorksDupes` works a station that calls
in again while you are running, and `EsmSendsCorrectedCall` sends the call again
in front of the last message when you have corrected it — copy `SM3AB`, fix it
to `SM3ABC`, and the key sends `SM3ABC TU DL1ABC`.
### Editing the log
Double-click a cell in the log window to change it. The columns follow the

View File

@@ -78,8 +78,7 @@ passed over: the `{CAT…}` and radio-hex families, the audio and rotator macros
`{STEREOON}` and `{STEREOOFF}`, the call-stacking macros, `{CONDJUMP}`,
`{QSYCQ}`, `{FORCELOG}`, `{SwapContests}`, the digital TNC macros (`{ENTER}`,
`{ESC}`, `{CTRL-A}`…), and the wav-directory macros, which wait on voice keying.
`{EXCHSENT}` waits on ESM, which does not exist yet. 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
space, and the prosign characters `]`, `[`, `+` and `=`. They belong to the

View File

@@ -50,6 +50,22 @@ public sealed record Settings
/// to find the station by ear. N1MM calls it randomising.
public bool RandomizeSpots { get; init; }
/// Enter sends the message the contact has got to, rather than only logging
/// it. N1MM calls it ESM and most operators run with it on.
public bool EsmEnabled { get; init; }
/// N1MM's "big gun" switch: while searching, send the call once and be
/// ready to copy the exchange rather than calling again.
public bool EsmSendsCallOnce { get; init; }
/// Work a station that calls in again while running, which is what N1MM
/// recommends.
public bool EsmWorksDupes { get; init; } = true;
/// Send the call again in front of the message that ends the contact when
/// it has changed since it went out.
public bool EsmSendsCorrectedCall { get; init; } = true;
/// How far `{FREQUP}` and `{FREQDN}` move the radio. N1MM asks for the same
/// number in its Configurer.
public int FrequencyStepHertz { get; init; } = 100;

View File

@@ -9,7 +9,7 @@ public static class Messages
public static readonly IReadOnlyList<(string Key, string Label)> Keys =
[
("F1", "CQ"), ("F2", "Exch"), ("F3", "TU"), ("F4", "MyCall"),
("F5", "HisCall"), ("F6", "Repeat"), ("F7", "?"), ("F8", "Agn"),
("F5", "HisCall"), ("F6", "QSO B4"), ("F7", "?"), ("F8", "Agn"),
("F9", "Nr?"), ("F10", "Call?"), ("F11", "Spot"), ("F12", "Wipe"),
];
@@ -19,8 +19,8 @@ public static class Messages
"{SENTRST} {EXCH}",
"TU {MYCALL}",
"{MYCALL}",
"{CALL}",
"{EXCH} {EXCH}",
"!",
"QSO B4",
"?",
"AGN",
"NR?",
@@ -36,8 +36,8 @@ public static class Messages
"{EXCH}",
"THANK YOU {MYCALL}",
"{MYCALL}",
"{CALL}",
"{EXCH} {EXCH}",
"!",
"QSO BEFORE",
"PLEASE REPEAT",
"AGAIN",
"NUMBER PLEASE",

View File

@@ -0,0 +1,132 @@
using Avalonia.Controls;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// Enter sends the message the contact has got to, which is how most operators
/// run N1MM. What goes out is decided by `Esm`; this keeps the state that
/// decision needs and sends what it asks for.
public sealed partial class EntryWindow
{
private bool callSent;
private bool exchangeSent;
/// The call as it was when it last went out, for the corrected call.
private string sentCall = "";
/// What the last Enter sent, so `=` can send it again.
private IReadOnlyList<int> lastKeys = [];
private bool IsEsmOn => session.Settings.EsmEnabled;
/// The keys the next Enter would send. The entry window highlights them, so
/// the operator can see what is about to happen.
private EsmAction NextEsmAction()
{
if (Logging is null)
{
return EsmAction.Nothing;
}
return Esm.Decide(new EsmSituation
{
IsRunning = Logging.IsRunning,
HasCall = Logging.Entry.Call.Trim().Length > 0,
IsDupe = Logging.Verdict()?.IsDupe == true,
HasExchange = Logging.Entry.IsComplete,
CallSent = callSent,
ExchangeSent = exchangeSent,
SendsCallOnce = session.Settings.EsmSendsCallOnce,
WorksDupes = session.Settings.EsmWorksDupes,
});
}
private async Task RunEsmAsync()
{
EsmAction action = NextEsmAction();
if (action.IsNothing)
{
Status("nothing to send — the station is already in the log");
return;
}
await SendKeysAsync(action.Keys);
if (action.Logs)
{
LogContact();
}
Refresh();
}
private async Task SendKeysAsync(IReadOnlyList<int> keys)
{
if (Logging is null)
{
return;
}
lastKeys = keys;
foreach (int key in keys)
{
await SendKeyAsync(key, CorrectedCall(key));
Remember(key);
}
}
/// N1MM's "send corrected call": while running, a call that has changed
/// since it went out is sent again in front of the message that ends the
/// contact, so the station hears the call we are logging.
private string CorrectedCall(int key)
{
if (Logging is null
|| key != Esm.EndQso
|| !Logging.IsRunning
|| !session.Settings.EsmSendsCorrectedCall)
{
return "";
}
string call = Logging.Entry.Call.Trim();
return call.Length > 0 && sentCall.Length > 0 && call != sentCall ? call + " " : "";
}
private void Remember(int key)
{
if (key is Esm.MyCall or Esm.HisCall)
{
callSent = true;
sentCall = Logging is null ? "" : Logging.Entry.Call.Trim();
}
if (key == Esm.Exchange)
{
exchangeSent = true;
}
}
/// The contact is over, or the boxes have been cleared: the next station
/// starts from nothing sent.
private void ResetEsm()
{
callSent = false;
exchangeSent = false;
sentCall = "";
lastKeys = [];
}
/// `=` sends whatever Enter last sent, without deciding again.
private void RepeatLastMessage()
{
if (lastKeys.Count == 0)
{
Status("nothing has been sent yet");
return;
}
_ = SendKeysAsync(lastKeys);
}
private void ShowEsmKeys()
{
EsmItem.IsChecked = IsEsmOn;
IReadOnlyList<int> keys = IsEsmOn ? NextEsmAction().Keys : [];
for (int at = 0; at < functionButtons.Count; at++)
{
functionButtons[at].Classes.Set("esm", keys.Contains(at));
}
}
}

View File

@@ -15,7 +15,13 @@ public sealed partial class EntryWindow
/// actions after `{END}` still run.
private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2);
private void SendMessage(int index)
private void SendMessage(int index) => _ = SendKeyAsync(index);
/// Sends one function key's message. The task finishes when the text has
/// been handed to the keyer, not when it has gone out on the air, so a
/// second key can be queued behind it. `prefix` carries the corrected call
/// that ESM puts in front of the message that ends a contact.
private async Task SendKeyAsync(int index, string prefix = "")
{
if (Logging is null)
{
@@ -45,12 +51,15 @@ public sealed partial class EntryWindow
Status("no keyer — Config ▸ Keyer");
return;
}
string text = prefix + plan.Text;
// the box has to point at this radio before the key does
_ = session.PointTransmitAtAsync(radioNumber);
Status($"sending {plan.Text}");
_ = SendThenAsync(session.Keyer, plan.Text, plan.After);
await session.PointTransmitAtAsync(radioNumber);
Status($"sending {text}");
await SendThenAsync(session.Keyer, text, plan.After);
}
/// Hands the text to the keyer, and leaves what follows `{END}` to run on
/// its own once the keyer says the message has gone out.
private async Task SendThenAsync(MessageSender keyer, string text, IReadOnlyList<MessageAction> after)
{
try
@@ -62,10 +71,14 @@ public sealed partial class EntryWindow
Status(e.Message);
return;
}
if (after.Count == 0)
if (after.Count > 0)
{
return;
_ = RunWhenSentAsync(keyer, after);
}
}
private async Task RunWhenSentAsync(MessageSender keyer, IReadOnlyList<MessageAction> after)
{
await WhenSentAsync(keyer);
Dispatcher.UIThread.Post(() =>
{
@@ -130,6 +143,9 @@ public sealed partial class EntryWindow
case MessageCommand.SpotMe:
SpotMe();
break;
case MessageCommand.ExchangeSent:
exchangeSent = true;
break;
// the manual's table has the two page macros the other way round;
// the keys they are named after move the frequency this way
case MessageCommand.FrequencyUp:

View File

@@ -284,6 +284,17 @@ public sealed partial class EntryWindow
private void OnClusterSettings(object? sender, RoutedEventArgs e) =>
Show(() => new TelnetWindow(session, Tune)).ShowClusters();
/// N1MM puts ESM on the entry window's Config menu, and remembers it
/// between runs.
private void OnToggleEsm(object? sender, RoutedEventArgs e)
{
session.Save(session.Settings with { EsmEnabled = !session.Settings.EsmEnabled });
Status(session.Settings.EsmEnabled
? "ESM on — Enter sends the message the contact has got to"
: "ESM off");
Refresh();
}
private async void OnNetworkSettings(object? sender, RoutedEventArgs e)
{
NetworkDialog dialog = new(session.Settings);

View File

@@ -22,6 +22,14 @@
<Setter Property="Padding" Value="6,3" />
<Setter Property="Margin" Value="0,0,3,0" />
</Style>
<!-- the key Enter would send next, while ESM is on -->
<Style Selector="Button.fkey.esm">
<Setter Property="Background" Value="#2980B9" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.fkey.esm:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#3498DB" />
</Style>
</Window.Styles>
<DockPanel>
@@ -57,6 +65,8 @@
<MenuItem Header="_Cluster…" Click="OnClusterSettings" />
<MenuItem Header="_Network…" Click="OnNetworkSettings" />
<MenuItem Header="_Keyer and messages…" Click="OnKeyerSettings" />
<MenuItem Name="EsmItem" Header="_ESM — Enter sends message" ToggleType="CheckBox"
Click="OnToggleEsm" />
<Separator />
<MenuItem Header="Call _History File…" Click="OnCallHistoryFile" />
<MenuItem Header="S_ub bands…" Click="OnSubBandSettings" />

View File

@@ -18,6 +18,7 @@ public sealed partial class EntryWindow : Window
private readonly int radioNumber;
private EntryWindow? secondRadio;
private readonly List<TextBox> boxes = [];
private readonly List<Button> functionButtons = [];
private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly Dictionary<Type, Window> openWindows = [];
private bool updating;
@@ -213,6 +214,10 @@ public sealed partial class EntryWindow : Window
return;
}
Logging.Entry[index] = box.Text ?? "";
if (index == 0 && Logging.Entry.Call.Trim().Length == 0)
{
ResetEsm();
}
Refresh();
}
@@ -237,9 +242,14 @@ public sealed partial class EntryWindow : Window
session.Alternating?.Stop();
_ = session.Keyer?.AbortAsync();
Logging.Wipe();
ResetEsm();
SyncBoxes();
boxes[0].Focus();
break;
case Key.OemPlus when IsEsmOn && FocusedIsEntryBox():
e.Handled = true;
RepeatLastMessage();
break;
case Key.Tab when e.KeyModifiers.HasFlag(KeyModifiers.Control)
&& e.KeyModifiers.HasFlag(KeyModifiers.Shift):
e.Handled = true;
@@ -297,6 +307,11 @@ public sealed partial class EntryWindow : Window
boxes[0].Focus();
return;
}
if (IsEsmOn)
{
_ = RunEsmAsync();
return;
}
if (!Logging.Entry.IsComplete)
{
MoveFocus(forward: true);
@@ -323,6 +338,7 @@ public sealed partial class EntryWindow : Window
Status($"could not log the contact: {e.Message}");
return;
}
ResetEsm();
SyncBoxes();
boxes[0].Focus();
}
@@ -379,6 +395,7 @@ public sealed partial class EntryWindow : Window
private void Refresh()
{
ShowEsmKeys();
if (Logging is null)
{
VerdictText.Text = "";
@@ -436,6 +453,7 @@ public sealed partial class EntryWindow : Window
private void BuildFunctionKeys()
{
FunctionKeys.Children.Clear();
functionButtons.Clear();
for (int at = 0; at < Messages.Keys.Count; at++)
{
int index = at;
@@ -444,6 +462,7 @@ public sealed partial class EntryWindow : Window
button.Classes.Add("fkey");
button.Click += (_, _) => RunFunctionKey(index);
FunctionKeys.Children.Add(button);
functionButtons.Add(button);
}
}
@@ -462,11 +481,19 @@ public sealed partial class EntryWindow : Window
return;
case 11:
Logging.Wipe();
ResetEsm();
SyncBoxes();
boxes[0].Focus();
return;
default:
// N1MM reserves F1 for CQ: pressing it while searching starts
// running
if (index == Esm.CallCq)
{
Logging.IsRunning = true;
}
SendMessage(index);
Refresh();
return;
}
}

109
src/Nonemm.Session/Esm.cs Normal file
View File

@@ -0,0 +1,109 @@
namespace Nonemm.Session;
/// What the entry window looks like when Enter is pressed, which is all ESM
/// needs to know to decide what to send.
public sealed record EsmSituation
{
public bool IsRunning { get; init; }
public bool HasCall { get; init; }
/// The station has been worked before on this band and mode.
public bool IsDupe { get; init; }
/// Every exchange box holds something the contest accepts.
public bool HasExchange { get; init; }
/// Our call has gone out for this station: F4 while searching, F5 and the
/// exchange while running.
public bool CallSent { get; init; }
public bool ExchangeSent { get; init; }
/// N1MM's "big gun" switch: while searching, send the call once and then be
/// ready to copy the exchange, rather than calling again.
public bool SendsCallOnce { get; init; }
/// N1MM's "work dupes when running", which it recommends: a station that
/// calls in again is worked rather than told it is in the log.
public bool WorksDupes { get; init; }
}
/// The function keys Enter sends, in order, and whether the contact is logged
/// afterwards. No keys and no logging means Enter does nothing, which is what
/// happens when a dupe calls in while searching.
public sealed record EsmAction(IReadOnlyList<int> Keys, bool Logs)
{
public static readonly EsmAction Nothing = new([], false);
public bool IsNothing => Keys.Count == 0 && !Logs;
}
/// Enter sends the message the contact has got to. The table is N1MM's, from
/// its function-key documentation: the state of the callsign and exchange
/// boxes, and whether the station is running or searching, decide what goes
/// out.
public static class Esm
{
/// Which function key is which. N1MM's layout, and the one the entry
/// window's buttons are labelled with.
public const int CallCq = 0;
public const int Exchange = 1;
public const int EndQso = 2;
public const int MyCall = 3;
public const int HisCall = 4;
public const int WorkedBefore = 5;
public const int Again = 7;
public static EsmAction Decide(EsmSituation now) =>
now.IsRunning ? Running(now) : Searching(now);
private static EsmAction Running(EsmSituation now)
{
if (!now.HasCall)
{
return new EsmAction([CallCq], false);
}
if (now.HasExchange)
{
return now.ExchangeSent
? new EsmAction([EndQso], true)
: new EsmAction([HisCall, Exchange], false);
}
if (now.IsDupe && !now.WorksDupes)
{
return new EsmAction([WorkedBefore], false);
}
return now.CallSent
? new EsmAction([Again], false)
: new EsmAction([HisCall, Exchange], false);
}
private static EsmAction Searching(EsmSituation now)
{
if (!now.HasCall)
{
return new EsmAction([MyCall], false);
}
if (now.HasExchange)
{
return now.ExchangeSent
? new EsmAction([], true)
: new EsmAction([Exchange], true);
}
if (now.IsDupe)
{
// a station already in the log is not called again
return EsmAction.Nothing;
}
return now.CallSent && now.SendsCallOnce
? new EsmAction([Again], false)
: new EsmAction([MyCall], false);
}
}

View File

@@ -21,6 +21,10 @@ public enum MessageCommand
/// Puts this station on the cluster.
SpotMe,
/// Says the exchange has gone out, for a key that sends it in some other
/// form than F2. ESM reads it.
ExchangeSent,
FrequencyUp,
FrequencyDown,

View File

@@ -91,6 +91,7 @@ public sealed record MessagePlan(
"S&P" => new MessageAction(MessageCommand.SearchAndPounce),
"SPACE" => new MessageAction(MessageCommand.Space),
"SPOTME" => new MessageAction(MessageCommand.SpotMe),
"EXCHSENT" => new MessageAction(MessageCommand.ExchangeSent),
"FREQUP" => new MessageAction(MessageCommand.FrequencyUp),
"FREQDN" => new MessageAction(MessageCommand.FrequencyDown),
"PGUP" => new MessageAction(MessageCommand.PageUp),

View File

@@ -0,0 +1,95 @@
namespace Nonemm.Session.Tests;
/// The cases are the rows of N1MM's ESM table, both columns.
public class EsmTests
{
private static EsmSituation Running(EsmSituation now) => now with { IsRunning = true };
[Fact]
public void AnEmptyWindowCallsCqOrSendsMyCall()
{
Assert.Equal([0], Esm.Decide(Running(new EsmSituation())).Keys);
Assert.Equal([3], Esm.Decide(new EsmSituation()).Keys);
}
[Fact]
public void ANewCallGetsHisCallAndTheExchangeWhileRunning()
{
EsmAction action = Esm.Decide(Running(new EsmSituation { HasCall = true }));
Assert.Equal([4, 1], action.Keys);
Assert.False(action.Logs);
}
[Fact]
public void CallingAgainWhileRunningAsksForARepeat()
{
EsmSituation now = Running(new EsmSituation { HasCall = true, CallSent = true });
Assert.Equal([7], Esm.Decide(now).Keys);
}
[Fact]
public void SearchingSendsMyCallAgainUnlessTheBigGunSwitchIsOn()
{
EsmSituation now = new() { HasCall = true, CallSent = true };
Assert.Equal([3], Esm.Decide(now).Keys);
Assert.Equal([7], Esm.Decide(now with { SendsCallOnce = true }).Keys);
}
[Fact]
public void AnExchangeInTheBoxesEndsTheContact()
{
EsmSituation now = new() { HasCall = true, HasExchange = true, ExchangeSent = false };
EsmAction running = Esm.Decide(Running(now));
Assert.Equal([4, 1], running.Keys);
Assert.False(running.Logs);
EsmAction searching = Esm.Decide(now);
Assert.Equal([1], searching.Keys);
Assert.True(searching.Logs);
}
[Fact]
public void OnceTheExchangeHasGoneOutEnterLogsTheContact()
{
EsmSituation now = new() { HasCall = true, HasExchange = true, ExchangeSent = true };
EsmAction running = Esm.Decide(Running(now));
Assert.Equal([2], running.Keys);
Assert.True(running.Logs);
EsmAction searching = Esm.Decide(now);
Assert.Empty(searching.Keys);
Assert.True(searching.Logs);
}
[Fact]
public void ADupeIsToldItIsInTheLogUnlessDupesAreWorked()
{
EsmSituation now = Running(new EsmSituation { HasCall = true, IsDupe = true });
Assert.Equal([5], Esm.Decide(now).Keys);
Assert.Equal([4, 1], Esm.Decide(now with { WorksDupes = true }).Keys);
}
[Fact]
public void ADupeWhileSearchingIsLeftAlone()
{
EsmAction action = Esm.Decide(new EsmSituation { HasCall = true, IsDupe = true });
Assert.True(action.IsNothing);
}
[Fact]
public void ADupeWithAnExchangeIsWorkedLikeAnyOtherStation()
{
EsmSituation now = new() { HasCall = true, IsDupe = true, HasExchange = true };
Assert.Equal([4, 1], Esm.Decide(Running(now)).Keys);
Assert.Equal([1], Esm.Decide(now).Keys);
Assert.True(Esm.Decide(now).Logs);
}
}