GrabIntoFocused puts text in whichever box has the cursor, whatever kind of value that box normally holds. It is what N1MM's digital window does on Ctrl+click, for the exchange element the contest has no box for. The entry window also says when a CQ has gone out, which the digital window needs to clear its grab list on: a run that starts again brings new callers. GrabList.N1mmDepth is the ten calls N1MM's grab window holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
452 lines
15 KiB
C#
452 lines
15 KiB
C#
using Avalonia.Threading;
|
|
using Nonemm.App.Dialogs;
|
|
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);
|
|
|
|
/// What sends this radio's messages: the digital engine on a digital mode,
|
|
/// and the CW or voice keyer otherwise. A message therefore goes out the
|
|
/// way the mode needs without the caller choosing.
|
|
private MessageSender? Sender =>
|
|
Logging?.Mode.Category == ModeCategory.Digital && session.DigitalKeyer is { IsReady: true } engine
|
|
? engine
|
|
: session.Keyer;
|
|
|
|
/// What to say when a message has nowhere to go. The mode decides which
|
|
/// keyer a message uses, so the mode is part of the answer: an operator on
|
|
/// a data mode who sees "no keyer for USB" knows to move the radio or the
|
|
/// band panel rather than to look at the keyer settings.
|
|
private string NoKeyer() =>
|
|
Logging is { } logging
|
|
? $"no keyer for {logging.Mode.Name} — Config ▸ Keyer"
|
|
: "no keyer — Config ▸ Keyer";
|
|
|
|
/// The twelve keys for this radio, which are a different twelve while
|
|
/// running and while searching, as N1MM's file holds them.
|
|
private IReadOnlyList<FunctionKey> Keys() =>
|
|
Messages
|
|
.For(
|
|
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
|
|
session.Settings.CwMessageFile,
|
|
session.Settings.PhoneMessageFile)
|
|
.Keys(Logging?.IsRunning ?? false);
|
|
|
|
/// Right-clicking a function key button opens the messages for the mode the
|
|
/// radio is in, which is where import and export live too.
|
|
private Task EditMessages() => EditMessages(Logging?.Mode.Category ?? ModeCategory.Cw);
|
|
|
|
private async Task EditMessages(ModeCategory mode)
|
|
{
|
|
string stored = mode == ModeCategory.Phone
|
|
? session.Settings.PhoneMessageFile
|
|
: session.Settings.CwMessageFile;
|
|
if (await new MessagesDialog(mode, stored, folder: session.Paths.FunctionKeyMessages).ShowDialog<string?>(this) is not { } edited)
|
|
{
|
|
return;
|
|
}
|
|
session.Save(mode == ModeCategory.Phone
|
|
? session.Settings with { PhoneMessageFile = edited }
|
|
: session.Settings with { CwMessageFile = edited });
|
|
BuildFunctionKeys();
|
|
Refresh();
|
|
}
|
|
|
|
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 = Keys()[index].Message;
|
|
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 (Sender is null)
|
|
{
|
|
Status(NoKeyer());
|
|
return;
|
|
}
|
|
if (index == Esm.Exchange)
|
|
{
|
|
// the BARTG contest sends the time, and logs the one it sent
|
|
Logging.StampSentTime();
|
|
}
|
|
// only a CQ carries alternating CQ on to the other radio
|
|
if (index == Esm.CallCq)
|
|
{
|
|
session.Alternating?.CarryOn();
|
|
CqSent?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
else
|
|
{
|
|
session.Alternating?.Hold();
|
|
}
|
|
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(Sender, text, plan.After);
|
|
}
|
|
|
|
/// Sends a message that did not come from a function key. The QTC window
|
|
/// hands its traffic over this way, as N1MM's does through its own entry
|
|
/// window, so it goes out through the same keyer with the same macros
|
|
/// expanded.
|
|
/// `through` is the keyer the caller insists on: the digital window keys
|
|
/// its own engine whatever mode the entry window is on, which is what
|
|
/// N1MM's digital window does. Everyone else passes null and gets the keyer
|
|
/// the mode calls for. False when nothing went out, with the reason already
|
|
/// in the status line.
|
|
internal async Task<bool> SendTextAsync(string template, MessageSender? through = null)
|
|
{
|
|
if (Logging is null || template.Trim().Length == 0)
|
|
{
|
|
return false;
|
|
}
|
|
MessagePlan plan = MessagePlan.Read(template, Logging, session.Other(Logging));
|
|
foreach (MessageAction action in plan.Before)
|
|
{
|
|
Run(action);
|
|
}
|
|
if (plan.Text.Length == 0)
|
|
{
|
|
// a button that only acts: {WIPE}, {LOG}, {RUN}
|
|
return true;
|
|
}
|
|
if ((through ?? Sender) is not { } keyer)
|
|
{
|
|
Status(NoKeyer());
|
|
return false;
|
|
}
|
|
await session.PointTransmitAtAsync(radioNumber);
|
|
Status($"sending {plan.Text}");
|
|
await SendThenAsync(keyer, plan.Text, plan.After);
|
|
return true;
|
|
}
|
|
|
|
/// 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
|
|
{
|
|
sending = true;
|
|
Refresh();
|
|
await keyer.SendAsync(text);
|
|
}
|
|
catch (InvalidOperationException e)
|
|
{
|
|
sending = false;
|
|
Status(e.Message);
|
|
return;
|
|
}
|
|
_ = RunWhenSentAsync(keyer, after);
|
|
}
|
|
|
|
/// Waits for the keyer, turns the transmit light off, and runs whatever
|
|
/// stood after `{END}`.
|
|
private async Task RunWhenSentAsync(MessageSender keyer, IReadOnlyList<MessageAction> after)
|
|
{
|
|
await WhenSentAsync(keyer);
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
sending = false;
|
|
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:
|
|
sending = false;
|
|
_ = Sender?.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;
|
|
case MessageCommand.StackAnother:
|
|
StackAnother();
|
|
break;
|
|
case MessageCommand.SwapWithStack:
|
|
SwapWithStack();
|
|
break;
|
|
case MessageCommand.LogThenPop:
|
|
LogThenPop();
|
|
break;
|
|
case MessageCommand.ClearStack:
|
|
Logging.Stack.Clear();
|
|
ShowCallStack();
|
|
break;
|
|
// the digital engine keys itself while it has text to send, so
|
|
// {TX} and {RX} only matter when a macro wants the transmitter
|
|
// held open around what it sends
|
|
case MessageCommand.StartTransmit:
|
|
_ = session.Digital?.SetPttAsync(true);
|
|
break;
|
|
case MessageCommand.ReturnToReceive:
|
|
_ = session.Digital?.ReturnToReceiveAsync();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// N1MM's {STACKANOTHER} and Ctrl+Alt+G.
|
|
private void StackAnother()
|
|
{
|
|
if (Logging is null || !Logging.StackAnother())
|
|
{
|
|
return;
|
|
}
|
|
SyncBoxes();
|
|
boxes[0].Focus();
|
|
ShowCallStack();
|
|
Status($"{Logging.Stack.Count} stacked");
|
|
}
|
|
|
|
/// N1MM's Alt+G.
|
|
private void GrabStacked()
|
|
{
|
|
if (Logging is null || !Logging.GrabStacked())
|
|
{
|
|
return;
|
|
}
|
|
SyncBoxes();
|
|
boxes[0].Focus();
|
|
ShowCallStack();
|
|
}
|
|
|
|
/// N1MM's Alt+D: the call on top of the stack is dropped without being
|
|
/// worked.
|
|
private void DropStacked()
|
|
{
|
|
if (Logging is null || Logging.Stack.IsEmpty)
|
|
{
|
|
return;
|
|
}
|
|
Status($"{Logging.Stack.Next()} dropped");
|
|
ShowCallStack();
|
|
}
|
|
|
|
/// N1MM's {SOCALLSTACK}.
|
|
private void SwapWithStack()
|
|
{
|
|
if (Logging is null)
|
|
{
|
|
return;
|
|
}
|
|
Logging.SwapWithStack();
|
|
SyncBoxes();
|
|
boxes[0].Focus();
|
|
ShowCallStack();
|
|
}
|
|
|
|
/// N1MM's {LOGTHENPOP} and {LOGTHENNEXT}: one key logs the contact and
|
|
/// brings the next caller into the boxes, so a run does not stop between
|
|
/// callers.
|
|
private void LogThenPop()
|
|
{
|
|
if (Logging is null || !Logging.IsRunning)
|
|
{
|
|
return;
|
|
}
|
|
LogContact();
|
|
GrabStacked();
|
|
}
|
|
|
|
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
|
|
|| Sender is null
|
|
|| !int.TryParse(key, out int number))
|
|
{
|
|
return;
|
|
}
|
|
string template = Messages
|
|
.For(other.Mode.Category, session.Settings.CwMessageFile, session.Settings.PhoneMessageFile)
|
|
.Keys(other.IsRunning)[number - 1]
|
|
.Message;
|
|
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 Sender!.SendAsync(text);
|
|
}
|
|
catch (InvalidOperationException e)
|
|
{
|
|
Status(e.Message);
|
|
}
|
|
}
|
|
}
|