Add the digital interface, running MMTTY under Wine
MMTTY and 2Tone have no socket or pipe interface: N1MM hosts XMMT.ocx and exchanges window messages with the engine. A Linux process cannot load that control, so bridge/nonemm-mmtty-bridge.exe hosts it under Wine and passes lines over its standard input and output. docs/digital-bridge.md states the protocol and what the control needs. The window is N1MM's: receive pane with coloured callsigns, grab list, call stacking, twenty-four macro buttons and the engine controls. One left click copies what is under it — a callsign to the callsign box, anything else to the exchange box the contest keeps for that kind of value. Config > Digital registers XMMT.ocx in the Wine prefix on its own, making the prefix first if it is not there. The engine, the bridge and the control start at the copies shipped beside the program. The bridge reads the control's own events. OnTranslateMessage carries only the messages the control has no event for, so nothing was ever decoded through it. hamlib's data mode names read as digital modes now, and a mode typed into the callsign box changes mode the way a frequency changes band, so a station with no radio can reach RTTY at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
@@ -16,6 +16,23 @@ public sealed partial class EntryWindow
|
||||
/// 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() =>
|
||||
@@ -74,9 +91,9 @@ public sealed partial class EntryWindow
|
||||
// a key that only acts: {WIPE}, {RUN}, {TELNET sh/dx}
|
||||
return;
|
||||
}
|
||||
if (session.Keyer is null)
|
||||
if (Sender is null)
|
||||
{
|
||||
Status("no keyer — Config ▸ Keyer");
|
||||
Status(NoKeyer());
|
||||
return;
|
||||
}
|
||||
if (index == Esm.Exchange)
|
||||
@@ -97,18 +114,23 @@ public sealed partial class EntryWindow
|
||||
// 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);
|
||||
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.
|
||||
private async Task SendTextAsync(string template)
|
||||
/// `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;
|
||||
return false;
|
||||
}
|
||||
MessagePlan plan = MessagePlan.Read(template, Logging, session.Other(Logging));
|
||||
foreach (MessageAction action in plan.Before)
|
||||
@@ -117,16 +139,18 @@ public sealed partial class EntryWindow
|
||||
}
|
||||
if (plan.Text.Length == 0)
|
||||
{
|
||||
return;
|
||||
// a button that only acts: {WIPE}, {LOG}, {RUN}
|
||||
return true;
|
||||
}
|
||||
if (session.Keyer is null)
|
||||
if ((through ?? Sender) is not { } keyer)
|
||||
{
|
||||
Status("no keyer — Config ▸ Keyer");
|
||||
return;
|
||||
Status(NoKeyer());
|
||||
return false;
|
||||
}
|
||||
await session.PointTransmitAtAsync(radioNumber);
|
||||
Status($"sending {plan.Text}");
|
||||
await SendThenAsync(session.Keyer, plan.Text, plan.After);
|
||||
await SendThenAsync(keyer, plan.Text, plan.After);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Hands the text to the keyer, and leaves what follows `{END}` to run on
|
||||
@@ -236,7 +260,7 @@ public sealed partial class EntryWindow
|
||||
break;
|
||||
case MessageCommand.StopSending:
|
||||
sending = false;
|
||||
_ = session.Keyer?.AbortAsync();
|
||||
_ = Sender?.AbortAsync();
|
||||
break;
|
||||
case MessageCommand.Telnet:
|
||||
SendToCluster(action.Argument);
|
||||
@@ -267,6 +291,15 @@ public sealed partial class EntryWindow
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +417,7 @@ public sealed partial class EntryWindow
|
||||
{
|
||||
if (Logging is null
|
||||
|| session.Other(Logging) is not { } other
|
||||
|| session.Keyer is null
|
||||
|| Sender is null
|
||||
|| !int.TryParse(key, out int number))
|
||||
{
|
||||
return;
|
||||
@@ -407,7 +440,7 @@ public sealed partial class EntryWindow
|
||||
await session.PointTransmitAtAsync(radio);
|
||||
try
|
||||
{
|
||||
await session.Keyer!.SendAsync(text);
|
||||
await Sender!.SendAsync(text);
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user