N1MM blocks the transmitter when a multi-operator entry is about to break its own class. This program does not touch the transmitter: a logger that decides for itself when the operator may not send loses a contact every time it is wrong, and it is wrong whenever it has stale information about the other stations — which, on a network, is whenever a laptop has just been unplugged. So the entry window says so instead. While this station is transmitting and the entry is over what it is allowed, a red box goes across the top of the window naming the station and the band. It is above the frequency and under the menu, so nothing it covers is anything the operator needs while he is sending, and it is big enough to read from the other side of the room. `TransmitRules` is what it asks: | Entry | Allowed | |---|---| | one transmitter | one signal | | two transmitters | two signals, on different bands | | anything else | one signal per band | Two on one band is wrong in every multi-operator class, so it is checked whatever the category says. A station that has not said which band it is on counts as somewhere else: a guess there puts the box in front of an operator doing nothing wrong. This is also the first thing that sends `XMIT`. The message was written and nothing called it, so the transmit column of everybody's network status window stayed empty and no station knew when another was sending. The entry window now says when its key goes down and what it is sending. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
510 lines
17 KiB
C#
510 lines
17 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.
|
|
///
|
|
/// On a digital mode they come from the digital macros instead, which is
|
|
/// where N1MM reads them from as well: its entry window loads the RTTYBTN
|
|
/// set there rather than the CW file. Without this the keys held CW text
|
|
/// with no `{TX}` in it, so pressing one fed the engine without keying the
|
|
/// transmitter and nothing went on the air.
|
|
private IReadOnlyList<FunctionKey> Keys() =>
|
|
Logging?.Mode.Category == ModeCategory.Digital
|
|
? DigitalKeys()
|
|
: Messages
|
|
.For(
|
|
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
|
|
session.Settings.CwMessageFile,
|
|
session.Settings.PhoneMessageFile)
|
|
.Keys(Logging?.IsRunning ?? false);
|
|
|
|
/// The first ten digital macros, then Spot and Wipe. F11 and F12 are what
|
|
/// they are in every other mode, so the labels say what the buttons do;
|
|
/// the macros past the tenth are on the digital window's own buttons.
|
|
private IReadOnlyList<FunctionKey> DigitalKeys()
|
|
{
|
|
IReadOnlyList<FunctionKey> macros = Messages.Digital(session.Settings.DigitalMessageFile).Buttons;
|
|
List<FunctionKey> keys = [.. macros.Take(MessageFile.KeyCount - 2)];
|
|
keys.Add(new FunctionKey("Spot", ""));
|
|
keys.Add(new FunctionKey("Wipe", ""));
|
|
return keys;
|
|
}
|
|
|
|
/// 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}, or an {RX} on its
|
|
// own, which has nothing to wait for
|
|
foreach (MessageAction action in plan.After)
|
|
{
|
|
Run(action);
|
|
}
|
|
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
|
|
{
|
|
// what the other operators read in their network status window
|
|
sendingWhat = text.Length > 40 ? text[..40] : text;
|
|
sending = true;
|
|
Refresh();
|
|
await keyer.SendAsync(text);
|
|
}
|
|
catch (InvalidOperationException e)
|
|
{
|
|
sending = false;
|
|
Status(e.Message);
|
|
return;
|
|
}
|
|
// `{RX}` goes to the keyer as soon as the message is in it, which is
|
|
// N1MM's ending: the text in one piece, the stop 400 ms behind it, and
|
|
// the engine unkeys itself at the last character. Waiting for the
|
|
// message to go out first leaves the engine empty, and a stop that
|
|
// arrives there does nothing. Everything else after `{END}` still
|
|
// waits.
|
|
foreach (MessageAction action in after)
|
|
{
|
|
if (action.Command == MessageCommand.ReturnToReceive)
|
|
{
|
|
Run(action);
|
|
}
|
|
}
|
|
_ = RunWhenSentAsync(
|
|
keyer,
|
|
[.. after.Where(action => action.Command != MessageCommand.ReturnToReceive)]);
|
|
}
|
|
|
|
/// 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;
|
|
// {TX} keys the transmitter and {RX} drops it. {RX} waits until
|
|
// everything waiting has gone out, so it ends the transmission
|
|
// rather than cutting it off
|
|
case MessageCommand.StartTransmit:
|
|
// through the keyer, which keys the engine before it feeds it
|
|
// the message
|
|
if (session.DigitalKeyer is { } keying)
|
|
{
|
|
keying.Transmit();
|
|
}
|
|
else
|
|
{
|
|
_ = session.Digital?.SetPttAsync(true);
|
|
}
|
|
break;
|
|
case MessageCommand.ReturnToReceive:
|
|
if (session.DigitalKeyer is { } digital)
|
|
{
|
|
digital.ReturnToReceiveWhenSent();
|
|
}
|
|
else
|
|
{
|
|
_ = 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);
|
|
}
|
|
}
|
|
}
|