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
parent 76b57cee1d
commit c8de74e24a
70 changed files with 4560 additions and 27 deletions

View File

@@ -4,6 +4,7 @@ using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Calls;
using Nonemm.Core.Country;
using Nonemm.Digital;
using Nonemm.Keying;
using Nonemm.Network;
using Nonemm.Rig;
@@ -26,6 +27,8 @@ public sealed class AppSession : IDisposable
private StationNetwork? network;
private MessageSender? keyer;
private AlternatingCq? alternating;
private MmttyEngine? digital;
private DigitalEngineSender? digitalSender;
private So2rBox? box;
/// Which cluster spots reach the bandmap. Rebuilt whenever the settings or
@@ -123,6 +126,14 @@ public sealed class AppSession : IDisposable
public MessageSender? Keyer => keyer;
/// The digital modem, started by the digital window rather than at startup:
/// it runs an engine under Wine, which is not something to do to an
/// operator who is working CW.
public MmttyEngine? Digital => digital;
/// The same engine as something a macro can be sent through.
public MessageSender? DigitalKeyer => digitalSender;
/// Alternating CQ, or null while there is no keyer. It needs two radios to
/// do anything, and a keyer that reports when a message has gone out.
public AlternatingCq? Alternating => alternating;
@@ -291,6 +302,46 @@ public sealed class AppSession : IDisposable
Changed?.Invoke(this, EventArgs.Empty);
}
/// Starts the digital engine under Wine and returns it. The engine that is
/// already running is handed back rather than started again.
public async Task<MmttyEngine> StartDigitalAsync()
{
if (digital is { IsConnected: true } running)
{
return running;
}
StopDigital();
MmttyOptions options = new()
{
EnginePath = Settings.DigitalEnginePath,
Number = ActiveRadioNumber,
Window = Enum.TryParse(Settings.DigitalEngineWindow, ignoreCase: true, out EngineWindow window)
? window
: EngineWindow.Normal,
OnTop = Settings.DigitalEngineOnTop,
PttPort = Settings.DigitalPttPort.Trim().Length > 0 ? Settings.DigitalPttPort : null,
};
WineBridgeChannel channel = new(
Settings.DigitalBridgePath,
Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null,
Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine");
MmttyEngine started = new(channel, options);
await started.StartAsync();
digital = started;
digitalSender = new DigitalEngineSender(started);
Changed?.Invoke(this, EventArgs.Empty);
return started;
}
public void StopDigital()
{
digitalSender?.Dispose();
digitalSender = null;
digital?.Dispose();
digital = null;
Changed?.Invoke(this, EventArgs.Empty);
}
/// Opens a connection per enabled radio. A second one makes the station
/// SO2R: both are read, but only the one the operator is on drives the
/// entry window.
@@ -534,6 +585,7 @@ public sealed class AppSession : IDisposable
public void Dispose()
{
StopDigital();
spotFlush.Dispose();
DisposeRadios();
cluster?.Dispose();

View File

@@ -186,6 +186,79 @@ public sealed record Settings
public IReadOnlyList<string> PhoneMessages { get; init; } = [];
/// The digital window's twenty-four macro buttons, in the same
/// `label,message` lines as a function key file. Empty means the built-in
/// set.
public string DigitalMessageFile { get; init; } = "";
public bool DigitalEnabled { get; init; }
/// The engine to run: MMTTY or 2Tone. The path is a Linux path; it is
/// handed to Wine in the form Wine expects. It starts at the copy shipped
/// beside the program.
public string DigitalEnginePath { get; init; } = ProgramFile("mmtty", "mmtty.exe");
public string DigitalBridgePath { get; init; } =
ProgramFile("bridge", "nonemm-mmtty-bridge.exe");
/// The XMMT.ocx the bridge hosts, as it sits beside the program. It is
/// only read when the control is registered in the Wine prefix; after that
/// the copy in the prefix is the one that is loaded.
public string DigitalControlPath { get; init; } = ProgramFile("bridge", "XMMT.ocx");
/// Empty means Wine's own default prefix.
public string DigitalWinePrefix { get; init; } = "";
public string DigitalWineCommand { get; init; } = "wine";
/// `Normal`, `Small`, `Medium1` or `Medium2`, which are MMTTY's four
/// window sizes.
public string DigitalEngineWindow { get; init; } = "Normal";
public bool DigitalEngineOnTop { get; init; } = true;
/// The port MMTTY keys FSK and PTT on. Empty reads it from Mmtty.INI, which
/// is where MMTTY itself keeps it.
public string DigitalPttPort { get; init; } = "";
/// The mark tone the Align button moves the engine to. MMTTY's own default.
public int DigitalMarkHertz { get; init; } = 2125;
/// Colour the callsign itself, or the ground behind it. N1MM offers both.
public bool DigitalHighlightBackground { get; init; }
/// `everything`, `notdupes` or `knowncalls`.
public string DigitalGrabFilter { get; init; } = "everything";
public bool DigitalGrabNewestFirst { get; init; } = true;
/// `multipliers`, `firstin`, `lastin` or `disabled`.
public string DigitalCallStacking { get; init; } = "disabled";
/// N1MM's right-click option: a right click in the receive pane presses
/// Enter in the entry window instead of opening the menu.
public bool DigitalRightClickSendsEnter { get; init; }
/// With the right click sending Enter, whether the call stays in the box
/// afterwards. N1MM's "don't drop call".
public bool DigitalRightClickKeepsCall { get; init; }
/// A grabbed call is followed by a space, which moves to the exchange and
/// fills the report in, the way typing one does.
public bool DigitalGrabSendsSpace { get; init; } = true;
/// N1MM caps the receive pane's font at 14 points.
public int DigitalFontSize { get; init; } = 12;
/// A file shipped beside the program. MMTTY and the bridge are distributed
/// with Nonemm, so the paths are filled in already; a file that is not
/// there leaves the setting empty and the operator browses for it.
private static string ProgramFile(string folder, string name)
{
string path = Path.Combine(AppContext.BaseDirectory, folder, name);
return File.Exists(path) ? path : "";
}
/// Reflection rather than a generated serializer: the generated one hands
/// back null for every property the file leaves out instead of the value
/// the property is declared with.
@@ -322,6 +395,21 @@ public sealed record Settings
PhoneMessages = [],
};
}
if (settings.DigitalEnginePath.Length == 0)
{
settings = settings with { DigitalEnginePath = ProgramFile("mmtty", "mmtty.exe") };
}
if (settings.DigitalBridgePath.Length == 0)
{
settings = settings with
{
DigitalBridgePath = ProgramFile("bridge", "nonemm-mmtty-bridge.exe"),
};
}
if (settings.DigitalControlPath.Length == 0)
{
settings = settings with { DigitalControlPath = ProgramFile("bridge", "XMMT.ocx") };
}
return settings;
}
}

View File

@@ -0,0 +1,59 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.DigitalDialog"
Title="Digital engine" Width="620" SizeToContent="Height"
WindowStartupLocation="CenterOwner">
<Window.Styles>
<Style Selector="TextBlock.label">
<Setter Property="FontSize" Value="11" />
<Setter Property="Opacity" Value="0.7" />
<Setter Property="Margin" Value="0,8,0,1" />
</Style>
</Window.Styles>
<DockPanel Margin="14">
<StackPanel DockPanel.Dock="Top">
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="MMTTY and 2Tone are Windows programs. They run under Wine, driven through the same XMMT.ocx control N1MM uses, with a small bridge program in between. See docs/digital-bridge.md for setting the Wine prefix up." />
<TextBlock Classes="label" Text="Engine program (mmtty.exe or 2Tone.exe)" />
<Grid ColumnDefinitions="*,6,Auto">
<TextBox Name="EngineBox" />
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseEngine" />
</Grid>
<TextBlock Classes="label" Text="Bridge program (nonemm-mmtty-bridge.exe)" />
<Grid ColumnDefinitions="*,6,Auto">
<TextBox Name="BridgeBox" />
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseBridge" />
</Grid>
<Grid ColumnDefinitions="*,8,160" RowDefinitions="Auto,Auto">
<TextBlock Classes="label" Text="Wine prefix (empty for Wine's own)" />
<TextBox Name="PrefixBox" Grid.Row="1" />
<TextBlock Grid.Column="2" Classes="label" Text="Wine command" />
<TextBox Name="WineBox" Grid.Row="1" Grid.Column="2" />
</Grid>
<TextBlock Classes="label" Text="Control (XMMT.ocx), registered in the prefix above" />
<Grid ColumnDefinitions="*,6,Auto,6,Auto">
<TextBox Name="ControlBox" />
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseControl" />
<Button Grid.Column="4" Name="RegisterButton" Content="Register" Click="OnRegister" />
</Grid>
<TextBlock Name="RegisterText" TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Margin="0,4,0,0" IsVisible="False" />
<Grid ColumnDefinitions="160,8,160,8,*" RowDefinitions="Auto,Auto">
<TextBlock Classes="label" Text="Engine window" />
<ComboBox Name="WindowBox" Grid.Row="1" HorizontalAlignment="Stretch" />
<TextBlock Grid.Column="2" Classes="label" Text="PTT port (empty reads Mmtty.INI)" />
<TextBox Name="PttBox" Grid.Row="1" Grid.Column="2" />
<TextBlock Grid.Column="4" Classes="label" Text="Mark tone (Hz)" />
<TextBox Name="MarkBox" Grid.Row="1" Grid.Column="4" />
</Grid>
<CheckBox Name="OnTopBox" Content="Keep the engine window on top" Margin="0,10,0,0" />
</StackPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right"
Spacing="6" Margin="0,12,0,0">
<Button Content="Cancel" Click="OnCancel" />
<Button Content="Save" Click="OnSave" IsDefault="True" />
</StackPanel>
<Panel />
</DockPanel>
</Window>

View File

@@ -0,0 +1,105 @@
using System.Globalization;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Nonemm.App.Configuration;
using Nonemm.Digital;
namespace Nonemm.App.Dialogs;
/// Where the digital engine is and how it is started. It closes with the
/// settings, or with null when nothing is to be changed.
public sealed partial class DigitalDialog : Window
{
private static readonly string[] WindowSizes = ["Normal", "Small", "Medium1", "Medium2"];
private readonly Settings settings;
public DigitalDialog(Settings settings)
{
this.settings = settings;
InitializeComponent();
EngineBox.Text = settings.DigitalEnginePath;
BridgeBox.Text = settings.DigitalBridgePath;
ControlBox.Text = settings.DigitalControlPath;
PrefixBox.Text = settings.DigitalWinePrefix;
WineBox.Text = settings.DigitalWineCommand;
PttBox.Text = settings.DigitalPttPort;
MarkBox.Text = settings.DigitalMarkHertz.ToString(CultureInfo.InvariantCulture);
WindowBox.ItemsSource = WindowSizes;
WindowBox.SelectedItem =
WindowSizes.FirstOrDefault(s => s == settings.DigitalEngineWindow) ?? WindowSizes[0];
OnTopBox.IsChecked = settings.DigitalEngineOnTop;
}
private async void OnBrowseEngine(object? sender, RoutedEventArgs e) =>
EngineBox.Text = await PickAsync("The MMTTY or 2Tone program") ?? EngineBox.Text;
private async void OnBrowseBridge(object? sender, RoutedEventArgs e) =>
BridgeBox.Text = await PickAsync("The bridge program") ?? BridgeBox.Text;
private async void OnBrowseControl(object? sender, RoutedEventArgs e) =>
ControlBox.Text = await PickAsync("The XMMT.ocx control", "ActiveX controls", "*.ocx") ?? ControlBox.Text;
/// Copies XMMT.ocx into the prefix and registers it. It takes a few seconds
/// on a prefix Wine has to make first, so the button is held down until it
/// is over and the result is written under it.
private async void OnRegister(object? sender, RoutedEventArgs e)
{
RegisterButton.IsEnabled = false;
Say("Registering…");
WinePrefixSetup setup = new(
PrefixBox.Text?.Trim() is { Length: > 0 } prefix ? prefix : null,
WineBox.Text?.Trim() is { Length: > 0 } wine ? wine : "wine");
try
{
Say(await setup.RegisterAsync(ControlBox.Text?.Trim() ?? ""));
}
catch (Exception failure) when (failure is IOException or InvalidOperationException)
{
Say(failure.Message);
}
finally
{
RegisterButton.IsEnabled = true;
}
}
private void Say(string text)
{
RegisterText.Text = text;
RegisterText.IsVisible = true;
}
private async Task<string?> PickAsync(
string title,
string kind = "Windows programs",
string pattern = "*.exe")
{
IReadOnlyList<IStorageFile> picked = await StorageProvider.OpenFilePickerAsync(
new FilePickerOpenOptions
{
Title = title,
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType(kind) { Patterns = [pattern] }],
});
return picked.Count > 0 ? picked[0].Path.LocalPath : null;
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
private void OnSave(object? sender, RoutedEventArgs e) =>
Close(settings with
{
DigitalEnginePath = EngineBox.Text?.Trim() ?? "",
DigitalBridgePath = BridgeBox.Text?.Trim() ?? "",
DigitalControlPath = ControlBox.Text?.Trim() ?? "",
DigitalWinePrefix = PrefixBox.Text?.Trim() ?? "",
DigitalWineCommand = WineBox.Text?.Trim() is { Length: > 0 } wine ? wine : "wine",
DigitalPttPort = PttBox.Text?.Trim() ?? "",
DigitalMarkHertz = int.TryParse(MarkBox.Text, out int mark) ? mark : settings.DigitalMarkHertz,
DigitalEngineWindow = WindowBox.SelectedItem as string ?? "Normal",
DigitalEngineOnTop = OnTopBox.IsChecked == true,
DigitalEnabled = (EngineBox.Text?.Trim().Length ?? 0) > 0,
});
}

View File

@@ -0,0 +1,20 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.DigitalMacrosDialog"
Title="Digital macros" Width="620" Height="520"
WindowStartupLocation="CenterOwner">
<DockPanel Margin="14">
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="One line per button: the label, a comma, then the message. Twenty-four lines fill the three rows of eight. {TX} starts the transmission and {RX} ends it; {ENTER} sends a carriage return. The text macros are the same ones the function keys use." />
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="6" Margin="0,10,0,0">
<Button Content="Back to the defaults" Click="OnDefaults" />
<Panel Width="180" />
<Button Content="Cancel" Click="OnCancel" />
<Button Content="Save" Click="OnSave" IsDefault="True" />
</StackPanel>
<Border BorderThickness="1" BorderBrush="#40808080" CornerRadius="3" Margin="0,8,0,0">
<TextBox Name="TextArea" AcceptsReturn="True" FontFamily="monospace" FontSize="12"
BorderThickness="0" />
</Border>
</DockPanel>
</Window>

View File

@@ -0,0 +1,22 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace Nonemm.App.Dialogs;
/// The digital window's macro buttons, edited as their lines. It closes with
/// the text, or with null when nothing is to be changed.
public sealed partial class DigitalMacrosDialog : Window
{
public DigitalMacrosDialog(string text)
{
InitializeComponent();
TextArea.Text = text.Trim().Length > 0 ? text : Messages.DefaultDigital;
}
private void OnDefaults(object? sender, RoutedEventArgs e) =>
TextArea.Text = Messages.DefaultDigital;
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
private void OnSave(object? sender, RoutedEventArgs e) => Close(TextArea.Text ?? "");
}

View File

@@ -46,6 +46,45 @@ public static class Messages
F12 Wipe,
""";
/// The digital window's twenty-four buttons. N1MM keeps its own set in its
/// admin database, which is not a file this program reads, so these are
/// written from N1MM's published digital macro list: `{TX}` starts the
/// transmission, `{RX}` ends it, and the text macros are the ones every
/// other message uses.
public const string DefaultDigital =
"""
# The digital window's macro buttons, three rows of eight.
CQ,{TX}CQ CQ DE {MYCALL} {MYCALL} CQ K{ENTER}{RX}
Exch,{TX}{CALL} DE {MYCALL} {SENTRST} {EXCH} {SENTRST} {EXCH} K{ENTER}{RX}
TU,{TX}{CALL} TU DE {MYCALL} QRZ{ENTER}{RX}{LOG}
MyCall,{TX}{MYCALL} {MYCALL} K{ENTER}{RX}
HisCall,{TX}{CALL} DE {MYCALL} K{ENTER}{RX}
QSO B4,{TX}{CALL} QSO B4 QRZ DE {MYCALL}{ENTER}{RX}
Agn?,{TX}AGN AGN DE {MYCALL} K{ENTER}{RX}
Nr?,{TX}NR? NR? DE {MYCALL} K{ENTER}{RX}
Call?,{TX}CALL? CALL? DE {MYCALL} K{ENTER}{RX}
RST?,{TX}RST? RST? DE {MYCALL} K{ENTER}{RX}
QRZ,{TX}QRZ DE {MYCALL} {MYCALL} K{ENTER}{RX}
5NN,{TX}{SENTRST} {SENTRST} K{ENTER}{RX}
Test,{TX}RYRYRYRYRYRYRYRY{ENTER}{RX}
Grab,{TX}{CALL} DE {MYCALL} K{ENTER}{RX}
Wipe,{WIPE}
Log,{LOG}
Run,{RUN}
S&&P,{S&P}
Stack,{STACKANOTHER}
Pop,{LOGTHENPOP}
Spot,{SPOTME}
73,{TX}73 GL DE {MYCALL}{ENTER}{RX}
QRL?,{TX}QRL? DE {MYCALL}{ENTER}{RX}
Stop,{STOPTX}
""";
/// The stored digital macros, or the built-in ones while the operator has
/// not written their own.
public static DigitalMacros Digital(string stored) =>
DigitalMacros.Parse(stored.Trim().Length > 0 ? stored : DefaultDigital);
public static string DefaultFor(ModeCategory mode) =>
mode == ModeCategory.Phone ? DefaultPhone : DefaultCw;

View File

@@ -18,6 +18,15 @@
</PackageReference>
</ItemGroup>
<!-- MMTTY and the bridge are distributed with the program and are not in the
repository. Copying them beside the build is what fills the digital
engine and bridge paths in. -->
<ItemGroup>
<None Include="..\..\bridge\*.exe;..\..\bridge\*.ocx" LinkBase="bridge"
CopyToOutputDirectory="PreserveNewest" />
<None Include="..\..\mmtty\**\*" LinkBase="mmtty" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
<ProjectReference Include="..\Nonemm.Contests\Nonemm.Contests.csproj" />
@@ -28,5 +37,6 @@
<ProjectReference Include="..\Nonemm.Session\Nonemm.Session.csproj" />
<ProjectReference Include="..\Nonemm.Network\Nonemm.Network.csproj" />
<ProjectReference Include="..\Nonemm.Keying\Nonemm.Keying.csproj" />
<ProjectReference Include="..\Nonemm.Digital\Nonemm.Digital.csproj" />
</ItemGroup>
</Project>

View File

@@ -22,6 +22,10 @@ public static class Verdicts
/// The frequency we are calling CQ on.
public static IBrush Cq => Themes.Brush(Themes.Current.Cq);
/// Our own call, heard back from the other station. It is neither worth
/// working nor a dupe, so it gets the colour that means us.
public static IBrush MyCall => Cq;
/// Nothing typed yet is the same colour as a station worth working: that is
/// what N1MM leaves the callsign box.
public static IBrush Colour(Verdict? verdict) =>

View File

@@ -0,0 +1,228 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// The calls heard and what the pointer does to them: the grab list beside the
/// transmit pane, and the clicking and hovering in the receive pane.
public sealed partial class DigitalWindow
{
/// A callsign the modem decoded goes in the grab list, unless it is ours,
/// the one already being typed, or the filter turns it away.
private void Heard(string call)
{
if (Radio is not { } position
|| !GrabList.Wanted(call, position.Me.Callsign, position.Entry.Call)
|| !PassesFilter(position, call))
{
return;
}
grab.NewestFirst = session.Settings.DigitalGrabNewestFirst;
if (grab.Add(call))
{
ShowGrabList();
}
}
private bool PassesFilter(OperatingPosition position, string call) =>
session.Settings.DigitalGrabFilter.ToLowerInvariant() switch
{
"notdupes" => position.JudgeCall(call)?.IsDupe != true,
"knowncalls" => session.Calls.Holds(call) || position.Log.WorkedBefore(call).Count > 0,
_ => true,
};
private void ShowGrabList()
{
GrabCalls.Children.Clear();
if (Radio is not { } position)
{
return;
}
foreach (string call in grab.Calls)
{
GrabCalls.Children.Add(GrabRow(position, call));
}
}
private Control GrabRow(OperatingPosition position, string call)
{
TextBlock text = new()
{
Text = call,
FontFamily = new FontFamily("monospace"),
FontSize = session.Settings.DigitalFontSize,
Foreground = Verdicts.Colour(position.JudgeCall(call)),
};
Border row = new()
{
Padding = new Thickness(4, 1),
Cursor = new Cursor(StandardCursorType.Hand),
// a border with no background of its own is not clickable away from
// the letters, and the whole row is the target
Background = Brushes.Transparent,
Child = text,
};
row.PointerPressed += (_, e) => GrabFromList(call, e);
return row;
}
/// Left click grabs the call, right click drops it from the list, which is
/// what N1MM's grab window does.
private void GrabFromList(string call, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
{
grab.Remove(call);
ShowGrabList();
return;
}
Grab(call);
}
/// Puts a call in the entry window. With call stacking on and a call
/// already being typed, the one being typed goes on the stack first, so
/// neither is lost — N1MM's digital call stacking.
private void Grab(string call)
{
if (Radio is not { } position)
{
return;
}
position.Stack.Order = StackingOrder();
if (position.Stack.Order != StackOrder.Disabled
&& position.Entry.Call.Trim().Length > 0)
{
position.StackAnother();
}
grab.Remove(call);
ShowGrabList();
entry.GrabCall(call, session.Settings.DigitalGrabSendsSpace);
entry.Refresh();
}
/// N1MM's Grab button: the next call off the list.
private void OnGrabNext(object? sender, RoutedEventArgs e)
{
if (grab.Count > 0)
{
Grab(grab.Calls[0]);
}
}
private void OnReceivePointerMoved(object? sender, PointerEventArgs e)
{
if (e.Source is not TextBlock block || block.Text is not { } text || text.Trim().Length == 0)
{
Unhighlight();
return;
}
if (ReferenceEquals(block, hovered))
{
return;
}
Unhighlight();
hovered = block;
block.TextDecorations = TextDecorations.Underline;
MouseOverText.Text = DigitalReceiver.Scrub(text);
}
private void OnReceivePointerExited(object? sender, PointerEventArgs e)
{
Unhighlight();
Resume();
}
private void Unhighlight()
{
if (hovered is not null)
{
hovered.TextDecorations = null;
hovered = null;
}
MouseOverText.Text = "";
}
/// One left click copies the word under the pointer, the way N1MM does: a
/// callsign goes in the callsign box, anything else in the exchange box the
/// contest keeps for it. A click on nothing pauses the pane instead, so
/// text can be read while more arrives. A right click presses Enter in the
/// entry window when that option is on.
private void OnReceivePointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
{
RightClick();
return;
}
string clicked = e.Source is TextBlock block ? DigitalReceiver.Scrub(block.Text ?? "") : "";
if (clicked.Length == 0)
{
paused = true;
return;
}
Resume();
Copy(clicked);
}
/// Where a clicked word goes. N1MM's order: an empty callsign box takes a
/// call, a callsign box that already holds one takes another only onto the
/// call stack, and everything else is an exchange element.
private void Copy(string clicked)
{
if (Radio is not { } position)
{
return;
}
string call = DigitalReceiver.TrimToCall(clicked);
bool isCall = call.Length is >= 3 and < 13
&& !call.All(char.IsAsciiDigit)
&& DigitalReceiver.IsCallsign(call)
&& !string.Equals(call, position.Me.Callsign, StringComparison.OrdinalIgnoreCase);
bool waiting = position.Entry.Call.Trim().Length == 0;
if (isCall && (waiting || StackingOrder() != StackOrder.Disabled))
{
Grab(call);
return;
}
if (!waiting)
{
entry.GrabExchange(DigitalElement.Trim(clicked));
}
}
private void RightClick()
{
if (!session.Settings.DigitalRightClickSendsEnter)
{
return;
}
entry.PressEnter();
if (!session.Settings.DigitalRightClickKeepsCall && Radio is { } position)
{
position.Wipe();
entry.Refresh();
}
}
/// Lets the pane scroll again and prints what arrived while it was stopped.
private void Resume()
{
if (!paused)
{
return;
}
paused = false;
if (held.Length == 0)
{
return;
}
string waiting = held.ToString();
held.Clear();
Print(waiting);
}
}

View File

@@ -0,0 +1,180 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Nonemm.App.Dialogs;
using Nonemm.Digital;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// The macro buttons and the transmit pane: what goes out, and the engine
/// controls beside them.
public sealed partial class DigitalWindow
{
private const int MacroColumns = 8;
private readonly List<Button> macroButtons = [];
private DigitalMacros Macros => Messages.Digital(session.Settings.DigitalMessageFile);
/// Three rows of eight, which is how N1MM lays its digital macros out.
private void BuildMacros()
{
for (int row = 0; row < DigitalMacros.ButtonCount / MacroColumns; row++)
{
UniformGrid grid = new() { Columns = MacroColumns, Rows = 1 };
Grid.SetRow(grid, row);
for (int column = 0; column < MacroColumns; column++)
{
int index = (row * MacroColumns) + column;
Button button = new() { Classes = { "macro" }, Tag = index };
button.Click += (_, _) => SendMacro(index);
button.AddHandler(PointerPressedEvent, OnMacroPointerPressed, RoutingStrategies.Tunnel);
macroButtons.Add(button);
grid.Children.Add(button);
}
MacroRows.Children.Add(grid);
}
}
private void LabelMacros()
{
DigitalMacros macros = Macros;
for (int index = 0; index < macroButtons.Count; index++)
{
FunctionKey macro = macros[index];
macroButtons[index].Content = macro.Label.Length > 0 ? macro.Label : $"{index + 1}";
macroButtons[index].IsEnabled = macro.Label.Length > 0 || macro.Message.Length > 0;
}
}
/// Right-clicking a macro button opens the macros for editing, the way
/// right-clicking a function key does in the entry window.
private void OnMacroPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
{
e.Handled = true;
_ = EditMacrosAsync();
}
}
/// A macro goes out through the entry window, so the action macros in it
/// act on the contact being worked. The text goes to this window's own
/// engine whatever mode the entry window is on: these buttons are the
/// engine's, the way N1MM's digital window keys MMTTY from its own buttons.
private async void SendMacro(int index)
{
string message = Macros[index].Message;
if (message.Length == 0)
{
return;
}
if (Running() is null)
{
return;
}
if (session.DigitalKeyer is not { IsReady: true } keyer)
{
StateText.Text = "the engine is not connected";
return;
}
if (!await entry.SendTextAsync(message, keyer))
{
StateText.Text = "nothing was sent — see the entry window";
}
entry.Refresh();
}
private async Task EditMacrosAsync()
{
if (await new DigitalMacrosDialog(session.Settings.DigitalMessageFile)
.ShowDialog<string?>(this) is not { } edited)
{
return;
}
session.Save(session.Settings with { DigitalMessageFile = edited });
LabelMacros();
}
/// What is typed in the transmit pane goes out as it is typed, which is how
/// N1MM's window works: the engine transmits each character as it arrives.
private void OnTransmitKeyDown(object? sender, KeyEventArgs e)
{
if (engine is not { IsConnected: true } running)
{
return;
}
if (e.Key == Key.Enter)
{
e.Handled = true;
_ = running.TypeAsync('\r');
TransmitBox.Text += "\n";
TransmitBox.CaretIndex = TransmitBox.Text.Length;
return;
}
if (e.Key == Key.Escape)
{
e.Handled = true;
_ = running.AbortAsync();
TransmitBox.Text = "";
}
}
private void OnTransmit(object? sender, RoutedEventArgs e) => _ = Running()?.SetPttAsync(true);
private void OnReceive(object? sender, RoutedEventArgs e) => _ = Running()?.AbortAsync();
private void OnClearTransmit(object? sender, RoutedEventArgs e) => TransmitBox.Text = "";
private void OnClearReceive(object? sender, RoutedEventArgs e)
{
ReceiveLines.Children.Clear();
line = null;
word = null;
held.Clear();
paused = false;
receiver.Clear();
}
/// Puts the demodulator back on the tone pair the operator works on.
private void OnAlign(object? sender, RoutedEventArgs e) =>
_ = Running()?.TuneAsync(session.Settings.DigitalMarkHertz);
private void OnHamDefault(object? sender, RoutedEventArgs e) =>
_ = Running()?.PostAsync(MmttyMessage.DefaultProfile, 0);
/// MMTTY's own setup dialog. The engine opens it beside its own window, so
/// it comes up wherever the engine window is.
private void OnEngineSetup(object? sender, RoutedEventArgs e) =>
_ = Running()?.PostAsync(MmttyMessage.Setup, 0);
private void OnToggleLock(object? sender, RoutedEventArgs e) => Toggle(MmttySettings.AfcBit);
private void OnToggleReverse(object? sender, RoutedEventArgs e) => Toggle(MmttySettings.ReverseBit);
/// The engine takes its switches as one word, so a toggle sends the whole
/// word back with one bit flipped.
private void Toggle(int bit)
{
if (Running() is not { } running)
{
return;
}
_ = running.SetSwitchesAsync(running.Switches ^ bit);
ShowState();
}
/// The engine, or null with the reason in the status line. A menu item or
/// button that needs the engine says why nothing happened rather than
/// doing nothing at all.
private MmttyEngine? Running()
{
if (engine is null)
{
StateText.Text = "the engine is not running — Engine ▸ Start";
}
return engine;
}
}

View File

@@ -0,0 +1,142 @@
using Avalonia.Interactivity;
using Nonemm.App.Configuration;
using Nonemm.App.Dialogs;
namespace Nonemm.App.Windows;
/// The window's menus: what the engine does, and N1MM's options for how the
/// receive pane and the grab list behave.
public sealed partial class DigitalWindow
{
/// N1MM caps the receive pane's font at fourteen points.
private const int LargestFont = 14;
private const int SmallestFont = 8;
private void ApplySettings()
{
Settings settings = session.Settings;
HighlightBackgroundItem.IsChecked = settings.DigitalHighlightBackground;
GrabSpaceItem.IsChecked = settings.DigitalGrabSendsSpace;
RightClickEnterItem.IsChecked = settings.DigitalRightClickSendsEnter;
RightClickKeepsCallItem.IsChecked = settings.DigitalRightClickKeepsCall;
GrabNewestFirstItem.IsChecked = settings.DigitalGrabNewestFirst;
string filter = settings.DigitalGrabFilter.ToLowerInvariant();
GrabEverythingItem.IsChecked = filter is not ("notdupes" or "knowncalls");
GrabNotDupesItem.IsChecked = filter == "notdupes";
GrabKnownItem.IsChecked = filter == "knowncalls";
string stacking = settings.DigitalCallStacking.ToLowerInvariant();
StackMultipliersItem.IsChecked = stacking == "multipliers";
StackFirstInItem.IsChecked = stacking == "firstin";
StackLastInItem.IsChecked = stacking == "lastin";
StackDisabledItem.IsChecked = stacking is not ("multipliers" or "firstin" or "lastin");
grab.NewestFirst = settings.DigitalGrabNewestFirst;
}
private void Store(Settings settings)
{
session.Save(settings);
ApplySettings();
ShowGrabList();
}
private void OnStartEngine(object? sender, RoutedEventArgs e) => _ = StartAsync();
private void OnStopEngine(object? sender, RoutedEventArgs e)
{
Detach();
session.StopDigital();
ShowState();
}
private async void OnEngineSettings(object? sender, RoutedEventArgs e)
{
if (await new DigitalDialog(session.Settings).ShowDialog<Settings?>(this) is not { } edited)
{
return;
}
Store(edited);
ShowState();
}
private void OnEditMacros(object? sender, RoutedEventArgs e) => _ = EditMacrosAsync();
private void OnLargerFont(object? sender, RoutedEventArgs e) => Resize(1);
private void OnSmallerFont(object? sender, RoutedEventArgs e) => Resize(-1);
private void Resize(int step)
{
int wanted = Math.Clamp(session.Settings.DigitalFontSize + step, SmallestFont, LargestFont);
Store(session.Settings with { DigitalFontSize = wanted });
foreach (var lineOfText in ReceiveLines.Children.OfType<Avalonia.Controls.WrapPanel>())
{
foreach (var block in lineOfText.Children.OfType<Avalonia.Controls.TextBlock>())
{
block.FontSize = wanted;
}
}
}
private void OnToggleHighlight(object? sender, RoutedEventArgs e) =>
Store(session.Settings with
{
DigitalHighlightBackground = !session.Settings.DigitalHighlightBackground,
});
private void OnToggleGrabSpace(object? sender, RoutedEventArgs e) =>
Store(session.Settings with
{
DigitalGrabSendsSpace = !session.Settings.DigitalGrabSendsSpace,
});
private void OnToggleRightClick(object? sender, RoutedEventArgs e) =>
Store(session.Settings with
{
DigitalRightClickSendsEnter = !session.Settings.DigitalRightClickSendsEnter,
});
private void OnToggleRightClickKeepsCall(object? sender, RoutedEventArgs e) =>
Store(session.Settings with
{
DigitalRightClickKeepsCall = !session.Settings.DigitalRightClickKeepsCall,
});
private void OnToggleGrabOrder(object? sender, RoutedEventArgs e) =>
Store(session.Settings with
{
DigitalGrabNewestFirst = !session.Settings.DigitalGrabNewestFirst,
});
private void OnGrabEverything(object? sender, RoutedEventArgs e) => Filter("everything");
private void OnGrabNotDupes(object? sender, RoutedEventArgs e) => Filter("notdupes");
private void OnGrabKnown(object? sender, RoutedEventArgs e) => Filter("knowncalls");
private void Filter(string which) =>
Store(session.Settings with { DigitalGrabFilter = which });
private void OnClearGrabList(object? sender, RoutedEventArgs e)
{
grab.Clear();
ShowGrabList();
}
private void OnStackMultipliers(object? sender, RoutedEventArgs e) => Stacking("multipliers");
private void OnStackFirstIn(object? sender, RoutedEventArgs e) => Stacking("firstin");
private void OnStackLastIn(object? sender, RoutedEventArgs e) => Stacking("lastin");
private void OnStackDisabled(object? sender, RoutedEventArgs e) => Stacking("disabled");
private void Stacking(string which)
{
Store(session.Settings with { DigitalCallStacking = which });
if (Radio is { } position)
{
position.Stack.Order = StackingOrder();
}
}
}

View File

@@ -0,0 +1,134 @@
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Nonemm.App.Windows"
x:Class="Nonemm.App.Windows.DigitalWindow"
Title="Digital Interface" Width="900" Height="640">
<Window.Styles>
<Style Selector="Button.macro">
<Setter Property="FontSize" Value="11" />
<Setter Property="Padding" Value="2,3" />
<Setter Property="Margin" Value="1" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.control">
<Setter Property="FontSize" Value="11" />
<Setter Property="Padding" Value="8,3" />
<Setter Property="Margin" Value="1" />
</Style>
<Style Selector="TextBlock.status">
<Setter Property="FontSize" Value="11" />
<Setter Property="Opacity" Value="0.7" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
</Window.Styles>
<DockPanel>
<Menu DockPanel.Dock="Top" Name="MenuBar">
<MenuItem Header="_Engine">
<MenuItem Header="Start" Click="OnStartEngine" />
<MenuItem Header="Stop" Click="OnStopEngine" />
<Separator />
<MenuItem Header="MMTTY setup window" Click="OnEngineSetup" />
<MenuItem Header="Ham default profile" Click="OnHamDefault" />
<MenuItem Header="Align to mark tone" Click="OnAlign" />
<Separator />
<MenuItem Header="Settings…" Click="OnEngineSettings" />
</MenuItem>
<MenuItem Header="_Setup">
<MenuItem Header="Macros…" Click="OnEditMacros" />
<Separator />
<MenuItem Header="Larger font" Click="OnLargerFont" />
<MenuItem Header="Smaller font" Click="OnSmallerFont" />
<Separator />
<MenuItem Header="Clear receive pane" Click="OnClearReceive" />
<MenuItem Header="Clear transmit pane" Click="OnClearTransmit" />
</MenuItem>
<MenuItem Header="_Options">
<MenuItem Name="HighlightBackgroundItem" Header="Highlight the background"
ToggleType="CheckBox" Click="OnToggleHighlight" />
<MenuItem Name="GrabSpaceItem" Header="A grabbed call is followed by a space"
ToggleType="CheckBox" Click="OnToggleGrabSpace" />
<Separator />
<MenuItem Name="RightClickEnterItem" Header="Right click presses Enter"
ToggleType="CheckBox" Click="OnToggleRightClick" />
<MenuItem Name="RightClickKeepsCallItem" Header="Right click keeps the call"
ToggleType="CheckBox" Click="OnToggleRightClickKeepsCall" />
<Separator />
<MenuItem Header="Grab list">
<MenuItem Name="GrabEverythingItem" Header="Everything heard"
ToggleType="Radio" GroupName="grab" Click="OnGrabEverything" />
<MenuItem Name="GrabNotDupesItem" Header="Not dupes"
ToggleType="Radio" GroupName="grab" Click="OnGrabNotDupes" />
<MenuItem Name="GrabKnownItem" Header="Known calls only"
ToggleType="Radio" GroupName="grab" Click="OnGrabKnown" />
<Separator />
<MenuItem Name="GrabNewestFirstItem" Header="Newest first"
ToggleType="CheckBox" Click="OnToggleGrabOrder" />
<MenuItem Header="Clear the list" Click="OnClearGrabList" />
</MenuItem>
</MenuItem>
<MenuItem Header="Call _stacking">
<MenuItem Name="StackMultipliersItem" Header="Multipliers first"
ToggleType="Radio" GroupName="stack" Click="OnStackMultipliers" />
<MenuItem Name="StackFirstInItem" Header="First in, first out"
ToggleType="Radio" GroupName="stack" Click="OnStackFirstIn" />
<MenuItem Name="StackLastInItem" Header="Last in, first out"
ToggleType="Radio" GroupName="stack" Click="OnStackLastIn" />
<MenuItem Name="StackDisabledItem" Header="Off"
ToggleType="Radio" GroupName="stack" Click="OnStackDisabled" />
</MenuItem>
</Menu>
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="Auto,*,Auto,Auto" Margin="6,2,6,4">
<TextBlock Classes="status" Name="MouseOverText" MinWidth="120" />
<TextBlock Grid.Column="1" Classes="status" Name="StateText" HorizontalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="status" Name="FigsText" Text="Letters" Margin="0,0,10,0" />
<Border Grid.Column="3" Name="TransmitDot" CornerRadius="7" Width="30" Height="16"
Background="Transparent" BorderThickness="1">
<TextBlock Text="TX" FontSize="10" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</Grid>
<StackPanel DockPanel.Dock="Bottom" Margin="4,0,4,2">
<Grid Name="MacroRows" RowDefinitions="Auto,Auto,Auto" />
<WrapPanel Margin="0,3,0,0">
<Button Classes="control" Content="Clr RX" Click="OnClearReceive" />
<Button Classes="control" Content="Align" Click="OnAlign" />
<Button Classes="control" Name="TransmitButton" Content="TX" Click="OnTransmit" />
<Button Classes="control" Content="RX" Click="OnReceive" />
<Button Classes="control" Content="Ham" Click="OnHamDefault" />
<Button Classes="control" Name="LockButton" Content="Lock" Click="OnToggleLock" />
<Button Classes="control" Name="ReverseButton" Content="Rev" Click="OnToggleReverse" />
<Button Classes="control" Content="CLR" Click="OnClearTransmit" />
<Button Classes="control" Content="Grab" Click="OnGrabNext" />
</WrapPanel>
</StackPanel>
<Grid RowDefinitions="3*,Auto,*" Margin="4,2">
<Border BorderThickness="1" BorderBrush="#40808080">
<ScrollViewer Name="ReceiveScroller" HorizontalScrollBarVisibility="Disabled">
<StackPanel Name="ReceiveLines" Margin="4,2"
PointerMoved="OnReceivePointerMoved"
PointerPressed="OnReceivePointerPressed"
PointerExited="OnReceivePointerExited" />
</ScrollViewer>
</Border>
<GridSplitter Grid.Row="1" Height="4" ResizeDirection="Rows" Background="Transparent" />
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,150">
<Border BorderThickness="1" BorderBrush="#40808080">
<TextBox Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
FontFamily="monospace" BorderThickness="0"
KeyDown="OnTransmitKeyDown" />
</Border>
<Border Grid.Column="2" BorderThickness="1" BorderBrush="#40808080">
<ScrollViewer Name="GrabScroller">
<StackPanel Name="GrabCalls" Margin="3,2" />
</ScrollViewer>
</Border>
</Grid>
</Grid>
</DockPanel>
</local:RefreshableWindow>

View File

@@ -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,
};
}

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)
{

View File

@@ -430,6 +430,10 @@ public sealed partial class EntryWindow
private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune));
/// N1MM's digital interface window, which is where RTTY is worked from.
private void OnShowDigital(object? sender, RoutedEventArgs e) =>
Show(() => new DigitalWindow(session, this, radioNumber));
private async void OnStationSettings(object? sender, RoutedEventArgs e)
{
StationDialog dialog = new(session.Settings.Station);

View File

@@ -120,6 +120,7 @@
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
<MenuItem Header="Score Summary" Click="OnShowScore" />
<MenuItem Header="Telnet" Click="OnShowTelnet" />
<MenuItem Header="Digital Interface" Click="OnShowDigital" />
</MenuItem>
</Menu>

View File

@@ -355,7 +355,7 @@ public sealed partial class EntryWindow : Window
// N1MM's Escape holds alternating CQ where it is rather than
// switching it off; the next CQ carries it on
session.Alternating?.Hold();
_ = session.Keyer?.AbortAsync();
_ = Sender?.AbortAsync();
Logging.Wipe();
ResetEsm();
SyncBoxes();
@@ -480,6 +480,18 @@ public sealed partial class EntryWindow : Window
boxes[0].Focus();
return;
}
if (Logging.PendingMode() is { } mode)
{
Logging.Tune(Logging.Frequency, mode);
// a radio that is connected is moved as well, or its next answer
// would put the mode back
_ = session.Radio?.SetModeAsync(mode);
Logging.Entry.Call = "";
SyncBoxes();
boxes[0].Focus();
Status($"mode {mode.Name}");
return;
}
if (IsEsmOn)
{
_ = RunEsmAsync();
@@ -531,6 +543,49 @@ public sealed partial class EntryWindow : Window
/// What space does: the callsign box and the exchange boxes, stepping over
/// the reports and over a box this station is not asked for, and round to
/// the callsign again. This is N1MM's NextTab.
/// The digital window hands a callsign over: the boxes are wiped and the
/// call typed in, which is what double-clicking one in N1MM's receive pane
/// does. With `space` the focus moves on to the exchange as though the
/// operator had pressed space, so the reports and the call history fill in.
internal void GrabCall(string call, bool space)
{
if (Logging is null || call.Trim().Length == 0)
{
return;
}
Logging.Wipe();
ResetEsm();
Logging.Entry.Call = call.Trim().ToUpperInvariant();
SyncBoxes();
if (space)
{
MoveFocus(forward: true);
}
boxes[Logging.Entry.Focus].Focus();
Refresh();
}
/// The digital window's click on something that is not a callsign: it goes
/// in the exchange box the contest keeps for that kind of value, and the
/// cursor follows it. False when the contest has no box that takes it.
internal bool GrabExchange(string text)
{
if (Logging is null || DigitalElement.FieldFor(Logging.Entry, text) is not { } field)
{
return false;
}
Logging.Entry[field] = text;
Logging.Entry.FocusOn(field);
SyncBoxes();
boxes[field].Focus();
Refresh();
return true;
}
/// The digital window's right click, which N1MM makes the same as pressing
/// Enter here.
internal void PressEnter() => OnEnter();
private void MoveFocus(bool forward)
{
if (Logging is null || boxes.Count == 0)
@@ -621,7 +676,7 @@ public sealed partial class EntryWindow : Window
{
sending = false;
session.Alternating?.Hold();
_ = session.Keyer?.AbortAsync();
_ = Sender?.AbortAsync();
Status("stopped sending");
}
@@ -708,7 +763,7 @@ public sealed partial class EntryWindow : Window
updating = false;
}
private void Refresh()
internal void Refresh()
{
ShowFunctionKeys();
ShowBandButtons();
@@ -1042,7 +1097,7 @@ public sealed partial class EntryWindow : Window
Status("nothing left to report to " + station.Text);
return;
}
QtcWindow window = new(session, Logging, station, direction, SendTextAsync);
QtcWindow window = new(session, Logging, station, direction, text => SendTextAsync(text));
bool saved = await window.ShowDialog<bool>(this);
Logging.Wipe();
SyncBoxes();