From 0c440ed865da0c86c04cd8e60afc955c78d6f10c Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 28 Aug 2026 07:59:11 +0000 Subject: [PATCH] Let Enter send the message the contact has got to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 25 ++++ docs/unfinished.md | 3 +- src/Nonemm.App/Configuration/Settings.cs | 16 +++ src/Nonemm.App/Messages.cs | 10 +- src/Nonemm.App/Windows/EntryWindow.Esm.cs | 132 +++++++++++++++++++ src/Nonemm.App/Windows/EntryWindow.Macros.cs | 28 +++- src/Nonemm.App/Windows/EntryWindow.Menu.cs | 11 ++ src/Nonemm.App/Windows/EntryWindow.axaml | 10 ++ src/Nonemm.App/Windows/EntryWindow.axaml.cs | 27 ++++ src/Nonemm.Session/Esm.cs | 109 +++++++++++++++ src/Nonemm.Session/MessageCommand.cs | 4 + src/Nonemm.Session/MessagePlan.cs | 1 + tests/Nonemm.Session.Tests/EsmTests.cs | 95 +++++++++++++ 13 files changed, 458 insertions(+), 13 deletions(-) create mode 100644 src/Nonemm.App/Windows/EntryWindow.Esm.cs create mode 100644 src/Nonemm.Session/Esm.cs create mode 100644 tests/Nonemm.Session.Tests/EsmTests.cs diff --git a/README.md b/README.md index c45e0b0..80c3430 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/unfinished.md b/docs/unfinished.md index 54b8e08..4cbee10 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -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 diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index aef872b..429212d 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -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; diff --git a/src/Nonemm.App/Messages.cs b/src/Nonemm.App/Messages.cs index e3bd54b..d08a95a 100644 --- a/src/Nonemm.App/Messages.cs +++ b/src/Nonemm.App/Messages.cs @@ -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", diff --git a/src/Nonemm.App/Windows/EntryWindow.Esm.cs b/src/Nonemm.App/Windows/EntryWindow.Esm.cs new file mode 100644 index 0000000..049b3ea --- /dev/null +++ b/src/Nonemm.App/Windows/EntryWindow.Esm.cs @@ -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 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 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 keys = IsEsmOn ? NextEsmAction().Keys : []; + for (int at = 0; at < functionButtons.Count; at++) + { + functionButtons[at].Classes.Set("esm", keys.Contains(at)); + } + } +} diff --git a/src/Nonemm.App/Windows/EntryWindow.Macros.cs b/src/Nonemm.App/Windows/EntryWindow.Macros.cs index e304b84..af7154c 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Macros.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Macros.cs @@ -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 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 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: diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs index b0c7d81..14ac143 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs @@ -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); diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index 1111499..a5734a2 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -22,6 +22,14 @@ + + + @@ -57,6 +65,8 @@ + diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index d93dead..25c65ed 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -18,6 +18,7 @@ public sealed partial class EntryWindow : Window private readonly int radioNumber; private EntryWindow? secondRadio; private readonly List boxes = []; + private readonly List