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