Run the action macros in a function key message
A function key message was read as text and nothing else, so a message file
written for N1MM lost half of what it said: {WIPE}, {LOG}, {RUN} and the rest
expanded to nothing and the key only sent characters.
Reading a message now produces a plan rather than a string. MessagePlan.Read
walks the template once and sorts what it finds into three parts: the actions
that run before anything goes out, the text that goes on the air, and the
actions that wait until it has gone. N1MM's {END} decides which side an action
falls on, and text after {END} is dropped, because the message is over by then.
So TU {LOG}{END}{WIPE} logs the contact, sends TU, and clears the boxes when the
key has finished sending.
The actions that are carried out: {WIPE}, {LOG}, {RUN}, {S&P}, {SPACE},
{SPOTME}, {STOPTX}, {FREQUP}, {FREQDN}, {PGUP}, {PGDN}, {JUMPRX}, {JUMPRXTX},
{CTRLF1} to {CTRLF12} for sending on the other radio, {TELNET sh/dx} for a
command to the cluster node, and {OTRSP TX2} for one to the SO2R box. The last
one wanted a way to send a command straight to the box, so So2rBox grew one.
The frequency steps are two settings, with N1MM's own two numbers behind them.
Waiting on the keyer is the part worth reading twice. What follows {END} runs
when the keyer says the message has gone out, which is the only honest moment:
timing it from the length of the text is a guess that goes wrong exactly when
the contest is busy. A keyer that does not report completion is taken at its
word as soon as the text is handed over, and one that goes quiet is given two
minutes before the actions run anyway, because never running them is worse.
Running it against a fake cwdaemon and a fake node showed the message text on
the keyer, the telnet command at the node, RUN lighting up before the message
and the boxes clearing after it. That is also what showed the entry window was
not redrawing between the actions and the message.
What is still read and passed over is written down: the CAT, audio, rotator,
stereo and call-stacking families, the digital TNC macros, and the CW keyer
characters < > ~ ] [ + = , which belong to the keyer rather than to the
expander. {EXCHSENT} waits on ESM, which is next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
250
src/Nonemm.App/Windows/EntryWindow.Macros.cs
Normal file
250
src/Nonemm.App/Windows/EntryWindow.Macros.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
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;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user