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:
2026-08-31 21:35:45 +00:00
co-authored by Claude Opus 5
parent 76b57cee1d
commit c8de74e24a
70 changed files with 4560 additions and 27 deletions
+46 -13
View File
@@ -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)
{