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 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(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 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 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 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); } } }