Files
Nonemm/src/Nonemm.App/Windows/EntryWindow.Macros.cs
ericek111 0c440ed865 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>
2026-08-28 07:59:11 +00:00

267 lines
8.5 KiB
C#

using Avalonia.Threading;
using Nonemm.Core;
using Nonemm.Keying;
using Nonemm.Session;
using Nonemm.Spotting;
namespace Nonemm.App.Windows;
/// Running a function key message: the action macros in it, the text that goes
/// on the air, and the actions N1MM's `{END}` holds back until the message has
/// gone out.
public sealed partial class EntryWindow
{
/// A keyer that says nothing after this long is taken as finished, so the
/// actions after `{END}` still run.
private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2);
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)
{
return;
}
string template = Messages.For(
Logging.Mode.Category,
session.Settings.CwMessages,
session.Settings.PhoneMessages)[index];
if (template.Length == 0)
{
return;
}
MessagePlan plan = MessagePlan.Read(template, Logging, session.Other(Logging));
foreach (MessageAction action in plan.Before)
{
Run(action);
}
Refresh();
if (plan.Text.Length == 0)
{
// a key that only acts: {WIPE}, {RUN}, {TELNET sh/dx}
return;
}
if (session.Keyer is null)
{
Status("no keyer — Config ▸ Keyer");
return;
}
string text = prefix + plan.Text;
// the box has to point at this radio before the key does
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
{
await keyer.SendAsync(text);
}
catch (InvalidOperationException e)
{
Status(e.Message);
return;
}
if (after.Count > 0)
{
_ = RunWhenSentAsync(keyer, after);
}
}
private async Task RunWhenSentAsync(MessageSender keyer, IReadOnlyList<MessageAction> after)
{
await WhenSentAsync(keyer);
Dispatcher.UIThread.Post(() =>
{
foreach (MessageAction action in after)
{
Run(action);
}
Refresh();
});
}
/// Waits for the keyer to say the message has gone out. A keyer that does
/// not report completion is taken at its word as soon as the text is sent,
/// because the alternative is never running what follows `{END}`.
private static async Task WhenSentAsync(MessageSender keyer)
{
if (!keyer.ReportsCompletion)
{
return;
}
TaskCompletionSource sent = new();
void Finished(object? sender, EventArgs e) => sent.TrySetResult();
keyer.Finished += Finished;
try
{
await sent.Task.WaitAsync(SendingPatience);
}
catch (TimeoutException)
{
}
finally
{
keyer.Finished -= Finished;
}
}
private void Run(MessageAction action)
{
if (Logging is null)
{
return;
}
switch (action.Command)
{
case MessageCommand.Wipe:
Logging.Wipe();
SyncBoxes();
boxes[0].Focus();
break;
case MessageCommand.Log:
LogContact();
break;
case MessageCommand.Run:
Logging.IsRunning = true;
break;
case MessageCommand.SearchAndPounce:
Logging.IsRunning = false;
break;
case MessageCommand.Space:
MoveFocus(forward: true);
break;
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:
Step(session.Settings.FrequencyStepHertz);
break;
case MessageCommand.FrequencyDown:
Step(-session.Settings.FrequencyStepHertz);
break;
case MessageCommand.PageUp:
Step(session.Settings.PageStepHertz);
break;
case MessageCommand.PageDown:
Step(-session.Settings.PageStepHertz);
break;
case MessageCommand.StopSending:
_ = session.Keyer?.AbortAsync();
break;
case MessageCommand.Telnet:
SendToCluster(action.Argument);
break;
case MessageCommand.JumpToOtherRadio:
session.SwapRadio();
break;
case MessageCommand.JumpToOtherRadioWithTransmit:
session.SwapRadio();
_ = session.PointTransmitAtAsync(session.ActiveRadioNumber);
break;
case MessageCommand.SendOnOtherRadio:
SendOnOtherRadio(action.Argument);
break;
case MessageCommand.Otrsp:
_ = session.Box?.SendCommandAsync(action.Argument);
break;
}
}
private void Step(int hertz)
{
if (Logging is null)
{
return;
}
Frequency moved = Frequency.FromHertz(Logging.Frequency.Hertz + hertz);
Logging.Tune(moved);
_ = session.Radio?.TuneAsync(moved);
Refresh();
}
/// N1MM only self-spots while running, and so does this: a station that is
/// not calling CQ has nothing to tell the cluster.
private void SpotMe()
{
if (Logging is null || session.Cluster is not { IsConnected: true } cluster)
{
Status("not connected to a cluster node");
return;
}
if (!Logging.IsRunning)
{
Status("self-spotting is for running only");
return;
}
Callsign me = Callsign.Parse(Logging.Me.Callsign);
string comment = MessageExpander.Expand(
session.Settings.SpotComment, Logging, session.Other(Logging));
_ = cluster.SendSpotAsync(Logging.Frequency, me, comment);
session.Bandmap.Add(new Spot(me, Logging.Frequency, DateTime.UtcNow, SpotSource.Operator));
Status($"spotted {me.Text} on {Logging.Frequency.Kilohertz:0.0}");
}
private void SendToCluster(string command)
{
if (session.Cluster is not { } cluster)
{
Status("not connected to a cluster node");
return;
}
_ = cluster.SendAsync(command);
Status($"cluster: {command}");
}
/// `{CTRLFn}` sends the other radio's message n while the keyboard stays
/// here, which is how an SO2R station CQs on the other radio.
private void SendOnOtherRadio(string key)
{
if (Logging is null
|| session.Other(Logging) is not { } other
|| session.Keyer is null
|| !int.TryParse(key, out int number))
{
return;
}
string template = Messages.For(
other.Mode.Category,
session.Settings.CwMessages,
session.Settings.PhoneMessages)[number - 1];
if (template.Length == 0)
{
return;
}
string text = MessageExpander.Expand(template, other, Logging);
_ = SendOnOtherRadioAsync(other.RadioNumber, text);
Status($"radio {other.RadioNumber}: {text}");
}
private async Task SendOnOtherRadioAsync(int radio, string text)
{
await session.PointTransmitAtAsync(radio);
try
{
await session.Keyer!.SendAsync(text);
}
catch (InvalidOperationException e)
{
Status(e.Message);
}
}
}