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:
@@ -0,0 +1,298 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Nonemm.App.Theming;
|
||||
using Nonemm.Digital;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// N1MM's digital interface window: what the modem decodes, what is about to go
|
||||
/// out, the calls heard, and the macro buttons. Every callsign in the receive
|
||||
/// pane is coloured by what working it would bring, and double-clicking one
|
||||
/// puts it in the entry window.
|
||||
public sealed partial class DigitalWindow : RefreshableWindow
|
||||
{
|
||||
/// How many lines of decoded text are kept in the pane. A contest weekend
|
||||
/// of RTTY is more than any window needs to hold.
|
||||
private const int KeptLines = 300;
|
||||
|
||||
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(45);
|
||||
|
||||
private readonly AppSession session;
|
||||
private readonly EntryWindow entry;
|
||||
private readonly int radioNumber;
|
||||
private readonly DigitalReceiver receiver = new();
|
||||
private readonly GrabList grab = new();
|
||||
|
||||
/// What arrived while the pane was paused, held until it starts again.
|
||||
private readonly StringBuilder held = new();
|
||||
|
||||
private WrapPanel? line;
|
||||
private TextBlock? word;
|
||||
private TextBlock? hovered;
|
||||
private MmttyEngine? engine;
|
||||
private bool paused;
|
||||
private bool lastWasReturn;
|
||||
|
||||
public DigitalWindow(AppSession session, EntryWindow entry, int radioNumber = 1)
|
||||
{
|
||||
this.session = session;
|
||||
this.entry = entry;
|
||||
this.radioNumber = radioNumber;
|
||||
InitializeComponent();
|
||||
receiver.CallHeard += (_, call) => Dispatcher.UIThread.Post(() => Heard(call));
|
||||
BuildMacros();
|
||||
ApplySettings();
|
||||
Attach(session.Digital);
|
||||
Refresh();
|
||||
Closed += (_, _) => Detach();
|
||||
}
|
||||
|
||||
private OperatingPosition? Radio =>
|
||||
session.Positions.FirstOrDefault(p => p.RadioNumber == radioNumber);
|
||||
|
||||
public override void Refresh()
|
||||
{
|
||||
ReceiveScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||
GrabScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||
ShowState();
|
||||
ShowGrabList();
|
||||
LabelMacros();
|
||||
}
|
||||
|
||||
/// Starts the engine if it is not running, and connects this window to it.
|
||||
private async Task StartAsync()
|
||||
{
|
||||
if (session.Settings.DigitalEnginePath.Trim().Length == 0
|
||||
|| session.Settings.DigitalBridgePath.Trim().Length == 0)
|
||||
{
|
||||
StateText.Text = "no engine — Engine ▸ Settings";
|
||||
return;
|
||||
}
|
||||
StateText.Text = "starting the engine…";
|
||||
try
|
||||
{
|
||||
Attach(await session.StartDigitalAsync().WaitAsync(Patience));
|
||||
}
|
||||
catch (Exception failure) when (failure is InvalidOperationException or TimeoutException)
|
||||
{
|
||||
StateText.Text = failure.Message;
|
||||
return;
|
||||
}
|
||||
ShowState();
|
||||
}
|
||||
|
||||
private void Attach(MmttyEngine? started)
|
||||
{
|
||||
if (started is null || ReferenceEquals(started, engine))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Detach();
|
||||
engine = started;
|
||||
started.Received += WhenReceived;
|
||||
started.TransmitChanged += WhenTransmitChanged;
|
||||
started.ConnectionChanged += WhenConnectionChanged;
|
||||
started.Reported += WhenReported;
|
||||
}
|
||||
|
||||
private void Detach()
|
||||
{
|
||||
if (engine is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
engine.Received -= WhenReceived;
|
||||
engine.TransmitChanged -= WhenTransmitChanged;
|
||||
engine.ConnectionChanged -= WhenConnectionChanged;
|
||||
engine.Reported -= WhenReported;
|
||||
engine = null;
|
||||
}
|
||||
|
||||
private void WhenReceived(object? sender, string text) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
receiver.Receive(text);
|
||||
Print(text);
|
||||
});
|
||||
|
||||
private void WhenTransmitChanged(object? sender, bool transmitting) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
TransmitDot.Background = transmitting
|
||||
? Themes.Brush(Themes.Current.TransmitLight)
|
||||
: Brushes.Transparent;
|
||||
});
|
||||
|
||||
private void WhenConnectionChanged(object? sender, bool connected) =>
|
||||
Dispatcher.UIThread.Post(ShowState);
|
||||
|
||||
private void WhenReported(object? sender, string text) =>
|
||||
Dispatcher.UIThread.Post(() => StateText.Text = text);
|
||||
|
||||
/// N1MM titles the window with where the radio is and what is decoding,
|
||||
/// which is how an operator with two of them tells them apart.
|
||||
private void ShowTitle()
|
||||
{
|
||||
string radio = $"DI{radioNumber}";
|
||||
string mode = Radio?.Mode.Name ?? "RTTY";
|
||||
string frequency = Radio is { } position
|
||||
? position.Frequency.Kilohertz.ToString("0.00", CultureInfo.InvariantCulture)
|
||||
: "";
|
||||
string what = session.Settings.DigitalEnginePath.Trim().Length > 0
|
||||
? Path.GetFileNameWithoutExtension(session.Settings.DigitalEnginePath)
|
||||
: "no engine";
|
||||
Title = $"{frequency} {radio} {mode} Mode - {what}";
|
||||
}
|
||||
|
||||
private void ShowState()
|
||||
{
|
||||
ShowTitle();
|
||||
StateText.Text = engine switch
|
||||
{
|
||||
{ IsConnected: true } running =>
|
||||
$"{Path.GetFileName(session.Settings.DigitalEnginePath)} {running.Version}"
|
||||
+ $" · mark {running.MarkHertz} Hz",
|
||||
not null => "the engine is not connected",
|
||||
_ => "the engine is not running",
|
||||
};
|
||||
LockButton.FontWeight = IsSwitchOn(MmttySettings.AfcBit) ? FontWeight.Bold : FontWeight.Normal;
|
||||
ReverseButton.FontWeight = IsSwitchOn(MmttySettings.ReverseBit) ? FontWeight.Bold : FontWeight.Normal;
|
||||
}
|
||||
|
||||
private bool IsSwitchOn(int bit) => engine is { } running && (running.Switches & bit) != 0;
|
||||
|
||||
/// Prints what the modem decoded, one word at a time so each word is its
|
||||
/// own control and can be coloured, pointed at and clicked.
|
||||
private void Print(string text)
|
||||
{
|
||||
if (paused)
|
||||
{
|
||||
held.Append(text);
|
||||
return;
|
||||
}
|
||||
foreach (char c in text)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '\r':
|
||||
NewLine();
|
||||
lastWasReturn = true;
|
||||
continue;
|
||||
// a modem that sends both ends one line, not two
|
||||
case '\n' when lastWasReturn:
|
||||
lastWasReturn = false;
|
||||
continue;
|
||||
case '\n':
|
||||
NewLine();
|
||||
continue;
|
||||
case ' ':
|
||||
lastWasReturn = false;
|
||||
EndWord();
|
||||
Line().Children.Add(Space());
|
||||
continue;
|
||||
default:
|
||||
lastWasReturn = false;
|
||||
Word().Text += c;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
TrimLines();
|
||||
ReceiveScroller.ScrollToEnd();
|
||||
}
|
||||
|
||||
private void NewLine()
|
||||
{
|
||||
EndWord();
|
||||
line = null;
|
||||
}
|
||||
|
||||
private WrapPanel Line()
|
||||
{
|
||||
if (line is null)
|
||||
{
|
||||
line = new WrapPanel();
|
||||
ReceiveLines.Children.Add(line);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
private TextBlock Word()
|
||||
{
|
||||
if (word is null)
|
||||
{
|
||||
word = NewBlock("");
|
||||
Line().Children.Add(word);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
private TextBlock Space() => NewBlock(" ");
|
||||
|
||||
private TextBlock NewBlock(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
FontFamily = new FontFamily("monospace"),
|
||||
FontSize = session.Settings.DigitalFontSize,
|
||||
};
|
||||
|
||||
/// A finished word is judged: one shaped like a callsign is coloured the
|
||||
/// way every other window colours a station.
|
||||
private void EndWord()
|
||||
{
|
||||
if (word is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string call = DigitalReceiver.TrimToCall(DigitalReceiver.Scrub(word.Text ?? ""));
|
||||
if (DigitalReceiver.IsCallsign(call))
|
||||
{
|
||||
Colour(word, call);
|
||||
}
|
||||
word = null;
|
||||
}
|
||||
|
||||
private void Colour(TextBlock block, string call)
|
||||
{
|
||||
if (Radio is not { } position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (string.Equals(call, position.Me.Callsign, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
block.Foreground = Verdicts.MyCall;
|
||||
return;
|
||||
}
|
||||
Contests.Verdict? verdict = position.JudgeCall(call);
|
||||
if (session.Settings.DigitalHighlightBackground)
|
||||
{
|
||||
block.Background = Verdicts.Background(verdict);
|
||||
block.Foreground = Themes.TextOn(Verdicts.BackgroundColour(verdict));
|
||||
return;
|
||||
}
|
||||
block.Foreground = Verdicts.Colour(verdict);
|
||||
}
|
||||
|
||||
private void TrimLines()
|
||||
{
|
||||
while (ReceiveLines.Children.Count > KeptLines)
|
||||
{
|
||||
ReceiveLines.Children.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
private StackOrder StackingOrder() =>
|
||||
session.Settings.DigitalCallStacking.ToLowerInvariant() switch
|
||||
{
|
||||
"multipliers" => StackOrder.MultipliersFirst,
|
||||
"firstin" => StackOrder.FirstIn,
|
||||
"lastin" => StackOrder.LastIn,
|
||||
_ => StackOrder.Disabled,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user