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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
59
src/Nonemm.App/Dialogs/DigitalDialog.axaml
Normal file
59
src/Nonemm.App/Dialogs/DigitalDialog.axaml
Normal 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>
|
||||
105
src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs
Normal file
105
src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs
Normal 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,
|
||||
});
|
||||
}
|
||||
20
src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml
Normal file
20
src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml
Normal 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>
|
||||
22
src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml.cs
Normal file
22
src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml.cs
Normal 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 ?? "");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
228
src/Nonemm.App/Windows/DigitalWindow.Grab.cs
Normal file
228
src/Nonemm.App/Windows/DigitalWindow.Grab.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
180
src/Nonemm.App/Windows/DigitalWindow.Macros.cs
Normal file
180
src/Nonemm.App/Windows/DigitalWindow.Macros.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
142
src/Nonemm.App/Windows/DigitalWindow.Menu.cs
Normal file
142
src/Nonemm.App/Windows/DigitalWindow.Menu.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
134
src/Nonemm.App/Windows/DigitalWindow.axaml
Normal file
134
src/Nonemm.App/Windows/DigitalWindow.axaml
Normal 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>
|
||||
298
src/Nonemm.App/Windows/DigitalWindow.axaml.cs
Normal file
298
src/Nonemm.App/Windows/DigitalWindow.axaml.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -62,6 +62,15 @@ public static class Modes
|
||||
index["PKT"] = Digital;
|
||||
index["DATA"] = Digital;
|
||||
index["SSB"] = Usb;
|
||||
// hamlib's own names, which is what rigctld answers `m` with. A radio
|
||||
// in one of its data modes is on a digital mode: that is how AFSK RTTY
|
||||
// is run, and without these the mode reads as the one before it.
|
||||
index["CWR"] = Cw;
|
||||
index["RTTYR"] = Rtty;
|
||||
index["PKTUSB"] = Digital;
|
||||
index["PKTLSB"] = Digital;
|
||||
index["PKTFM"] = Digital;
|
||||
index["PKTAM"] = Digital;
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
20
src/Nonemm.Digital/BridgeChannel.cs
Normal file
20
src/Nonemm.Digital/BridgeChannel.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// The line connection to a bridge process. What is behind it is Wine in
|
||||
/// production and a fake in the tests.
|
||||
public interface BridgeChannel : IDisposable
|
||||
{
|
||||
/// A line the bridge wrote. Raised on the reader thread.
|
||||
event EventHandler<string>? LineReceived;
|
||||
|
||||
/// The bridge stopped on its own. The text says what is known about why.
|
||||
event EventHandler<string>? Failed;
|
||||
|
||||
Task StartAsync(CancellationToken cancellation = default);
|
||||
|
||||
Task SendAsync(string line, CancellationToken cancellation = default);
|
||||
|
||||
/// The path as the engine sees it. Wine has its own drive letters, and the
|
||||
/// engine is started by name from a Windows command line.
|
||||
Task<string> ToWindowsPathAsync(string path, CancellationToken cancellation = default);
|
||||
}
|
||||
58
src/Nonemm.Digital/BridgeLine.cs
Normal file
58
src/Nonemm.Digital/BridgeLine.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// One line of the bridge protocol: a verb, then its fields, separated by tabs
|
||||
/// and ended by a newline. Tab, newline and backslash inside a field are
|
||||
/// escaped, so a field never splits a line. The C++ bridge writes and reads the
|
||||
/// same format; `docs/digital-bridge.md` states it for both sides.
|
||||
public static class BridgeLine
|
||||
{
|
||||
public static string Write(string verb, params string[] fields) =>
|
||||
fields.Length == 0 ? verb : verb + "\t" + string.Join("\t", fields.Select(Escape));
|
||||
|
||||
public static (string Verb, string[] Fields) Read(string line)
|
||||
{
|
||||
string[] parts = line.Split('\t');
|
||||
return (parts[0], parts.Skip(1).Select(Unescape).ToArray());
|
||||
}
|
||||
|
||||
private static string Escape(string field)
|
||||
{
|
||||
StringBuilder escaped = new(field.Length);
|
||||
foreach (char c in field)
|
||||
{
|
||||
_ = c switch
|
||||
{
|
||||
'\\' => escaped.Append("\\\\"),
|
||||
'\t' => escaped.Append("\\t"),
|
||||
'\r' => escaped.Append("\\r"),
|
||||
'\n' => escaped.Append("\\n"),
|
||||
_ => escaped.Append(c),
|
||||
};
|
||||
}
|
||||
return escaped.ToString();
|
||||
}
|
||||
|
||||
private static string Unescape(string field)
|
||||
{
|
||||
StringBuilder plain = new(field.Length);
|
||||
for (int i = 0; i < field.Length; i++)
|
||||
{
|
||||
if (field[i] != '\\' || i + 1 == field.Length)
|
||||
{
|
||||
plain.Append(field[i]);
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
plain.Append(field[i] switch
|
||||
{
|
||||
't' => '\t',
|
||||
'r' => '\r',
|
||||
'n' => '\n',
|
||||
_ => field[i],
|
||||
});
|
||||
}
|
||||
return plain.ToString();
|
||||
}
|
||||
}
|
||||
31
src/Nonemm.Digital/DigitalEngine.cs
Normal file
31
src/Nonemm.Digital/DigitalEngine.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// A digital modem that decodes what is on the air and sends what is typed:
|
||||
/// MMTTY or 2Tone behind the Wine bridge today, fldigi later. The window above
|
||||
/// it works through this and does not know which one is running.
|
||||
public interface DigitalEngine : IDisposable
|
||||
{
|
||||
/// The modem is running and has told us its version.
|
||||
bool IsConnected { get; }
|
||||
|
||||
bool IsTransmitting { get; }
|
||||
|
||||
/// Text the modem has decoded, in the pieces it arrived in: usually one
|
||||
/// character. Raised on the reader thread, so a handler that touches the
|
||||
/// screen has to post.
|
||||
event EventHandler<string>? Received;
|
||||
|
||||
event EventHandler<bool>? TransmitChanged;
|
||||
|
||||
event EventHandler<bool>? ConnectionChanged;
|
||||
|
||||
/// Starts the modem and waits until it is connected. Throws if it does not
|
||||
/// come up.
|
||||
Task StartAsync(CancellationToken cancellation = default);
|
||||
|
||||
/// Puts the text in the transmit buffer, which starts the transmission.
|
||||
Task SendAsync(string text, CancellationToken cancellation = default);
|
||||
|
||||
/// Drops whatever has not gone out yet, which is what Escape does.
|
||||
Task AbortAsync(CancellationToken cancellation = default);
|
||||
}
|
||||
46
src/Nonemm.Digital/DigitalEngineSender.cs
Normal file
46
src/Nonemm.Digital/DigitalEngineSender.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Nonemm.Keying;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// The digital engine as something that sends a message, so a macro goes out
|
||||
/// through the same expander, the same `{END}` handling and the same ESM as a
|
||||
/// CW message does.
|
||||
public sealed class DigitalEngineSender : MessageSender
|
||||
{
|
||||
private readonly DigitalEngine engine;
|
||||
|
||||
public DigitalEngineSender(DigitalEngine engine)
|
||||
{
|
||||
this.engine = engine;
|
||||
engine.TransmitChanged += WhenTransmitChanged;
|
||||
}
|
||||
|
||||
public bool IsReady => engine.IsConnected;
|
||||
|
||||
/// The engine says when it has stopped transmitting, which is the same
|
||||
/// thing a keyer reports.
|
||||
public bool ReportsCompletion => true;
|
||||
|
||||
public event EventHandler? Finished;
|
||||
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
||||
engine.SendAsync(text, cancellation);
|
||||
|
||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||
engine.AbortAsync(cancellation);
|
||||
|
||||
/// RTTY runs at the speed the engine is set to, so there is nothing to set
|
||||
/// per message.
|
||||
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public void Dispose() => engine.TransmitChanged -= WhenTransmitChanged;
|
||||
|
||||
private void WhenTransmitChanged(object? sender, bool transmitting)
|
||||
{
|
||||
if (!transmitting)
|
||||
{
|
||||
Finished?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
220
src/Nonemm.Digital/MmttyEngine.cs
Normal file
220
src/Nonemm.Digital/MmttyEngine.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// MMTTY or 2Tone, started and driven through the bridge. The startup is
|
||||
/// N1MM's: the engine is given a title, the PTT port out of Mmtty.INI and a
|
||||
/// command line, and an engine that fails to initialise is started again up to
|
||||
/// ten times, which is what N1MM's retry does.
|
||||
public sealed class MmttyEngine : DigitalEngine
|
||||
{
|
||||
private const int StartAttempts = 10;
|
||||
|
||||
private readonly BridgeChannel bridge;
|
||||
private readonly MmttyOptions options;
|
||||
private readonly Channel<string> lines = Channel.CreateUnbounded<string>();
|
||||
private readonly TimeSpan startPatience;
|
||||
private TaskCompletionSource<bool>? starting;
|
||||
private MmttySettings settings = new();
|
||||
private string command = "";
|
||||
private int attempts;
|
||||
private Task? reading;
|
||||
|
||||
public MmttyEngine(BridgeChannel bridge, MmttyOptions options, TimeSpan? startPatience = null)
|
||||
{
|
||||
this.bridge = bridge;
|
||||
this.options = options;
|
||||
this.startPatience = startPatience ?? TimeSpan.FromSeconds(30);
|
||||
bridge.LineReceived += (_, line) => lines.Writer.TryWrite(line);
|
||||
bridge.Failed += (_, why) => Stopped(why);
|
||||
}
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public bool IsTransmitting { get; private set; }
|
||||
|
||||
/// The version MMTTY reported when it connected.
|
||||
public string Version { get; private set; } = "";
|
||||
|
||||
public int MarkHertz { get; private set; }
|
||||
|
||||
public int SpaceHertz { get; private set; }
|
||||
|
||||
/// MMTTY's AFC, net and reverse bits. Kept here because a toggle is sent as
|
||||
/// the whole word, so the current one has to be known.
|
||||
public int Switches { get; private set; }
|
||||
|
||||
public event EventHandler<string>? Received;
|
||||
|
||||
public event EventHandler<bool>? TransmitChanged;
|
||||
|
||||
public event EventHandler<bool>? ConnectionChanged;
|
||||
|
||||
/// Anything the bridge says about itself, for the status line.
|
||||
public event EventHandler<string>? Reported;
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
settings = MmttySettings.Read(options.SettingsPath);
|
||||
Switches = settings.Switches;
|
||||
await bridge.StartAsync(cancellation).ConfigureAwait(false);
|
||||
command = options.CommandLine(
|
||||
await bridge.ToWindowsPathAsync(options.EnginePath, cancellation).ConfigureAwait(false));
|
||||
starting = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
attempts = 1;
|
||||
reading ??= Task.Run(ReadAsync, CancellationToken.None);
|
||||
await OpenAsync(cancellation).ConfigureAwait(false);
|
||||
await starting.Task.WaitAsync(startPatience, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(BridgeLine.Write("send", text), cancellation);
|
||||
|
||||
/// One typed character, which MMTTY transmits as it arrives.
|
||||
public Task TypeAsync(char character, CancellationToken cancellation = default) =>
|
||||
PostAsync(MmttyMessage.TypeCharacter, character, cancellation);
|
||||
|
||||
/// N1MM's abort, which is what its RX button and Escape do: drop PTT and
|
||||
/// let the engine stop where it is.
|
||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||
SetPttAsync(false, cancellation);
|
||||
|
||||
/// N1MM's `{TX}` and `{RX}`. MMTTY sends what is in its buffer before it
|
||||
/// drops PTT, so a macro that ends with `{RX}` still goes out in full.
|
||||
public Task SetPttAsync(bool on, CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(BridgeLine.Write("ptt", on ? "1" : "0"), cancellation);
|
||||
|
||||
public Task ReturnToReceiveAsync(CancellationToken cancellation = default) =>
|
||||
SetPttAsync(false, cancellation);
|
||||
|
||||
/// Moves the demodulator, keeping the shift the engine reported.
|
||||
public async Task TuneAsync(int markHertz, CancellationToken cancellation = default)
|
||||
{
|
||||
int shift = SpaceHertz - MarkHertz;
|
||||
await PostAsync(MmttyMessage.MarkFrequency, markHertz, cancellation).ConfigureAwait(false);
|
||||
await PostAsync(MmttyMessage.SpaceFrequency, markHertz + shift, cancellation)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task SetSwitchesAsync(int switches, CancellationToken cancellation = default) =>
|
||||
PostAsync(MmttyMessage.Switches, switches, cancellation);
|
||||
|
||||
public Task PostAsync(MmttyMessage message, int parameter, CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(
|
||||
BridgeLine.Write("post", ((int)message).ToString(), parameter.ToString()),
|
||||
cancellation);
|
||||
|
||||
/// Shuts the engine down and stops the bridge.
|
||||
public async Task StopAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
await bridge.SendAsync(BridgeLine.Write("quit"), cancellation).ConfigureAwait(false);
|
||||
if (reading is not null)
|
||||
{
|
||||
lines.Writer.TryComplete();
|
||||
await reading.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lines.Writer.TryComplete();
|
||||
bridge.Dispose();
|
||||
}
|
||||
|
||||
private Task OpenAsync(CancellationToken cancellation) =>
|
||||
bridge.SendAsync(
|
||||
BridgeLine.Write("open", options.Title, PttPort(), command),
|
||||
cancellation);
|
||||
|
||||
private string PttPort() => options.PttPort ?? settings.PttPort;
|
||||
|
||||
private async Task ReadAsync()
|
||||
{
|
||||
await foreach (string line in lines.Reader.ReadAllAsync().ConfigureAwait(false))
|
||||
{
|
||||
(string verb, string[] fields) = BridgeLine.Read(line);
|
||||
await ActOnAsync(verb, fields).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ActOnAsync(string verb, string[] fields)
|
||||
{
|
||||
switch (verb)
|
||||
{
|
||||
case "connected":
|
||||
Version = Field(fields, 0);
|
||||
IsConnected = true;
|
||||
starting?.TrySetResult(true);
|
||||
ConnectionChanged?.Invoke(this, true);
|
||||
break;
|
||||
case "disconnected":
|
||||
await DisconnectedAsync(Number(fields, 0)).ConfigureAwait(false);
|
||||
break;
|
||||
case "rx":
|
||||
Received?.Invoke(this, ((char)Number(fields, 0)).ToString());
|
||||
break;
|
||||
case "tx":
|
||||
IsTransmitting = Number(fields, 0) == 1;
|
||||
TransmitChanged?.Invoke(this, IsTransmitting);
|
||||
break;
|
||||
case "mark":
|
||||
MarkHertz = Number(fields, 0);
|
||||
break;
|
||||
case "space":
|
||||
SpaceHertz = Number(fields, 0);
|
||||
break;
|
||||
case "switch":
|
||||
Switches = Number(fields, 0);
|
||||
break;
|
||||
case "error":
|
||||
Stopped(Field(fields, 0));
|
||||
break;
|
||||
case "log":
|
||||
Reported?.Invoke(this, Field(fields, 0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Status 2 is MMTTY failing to come up, which happens often enough that
|
||||
/// N1MM starts it again rather than telling the operator.
|
||||
private async Task DisconnectedAsync(int status)
|
||||
{
|
||||
if (status != 2)
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionChanged?.Invoke(this, false);
|
||||
return;
|
||||
}
|
||||
if (attempts >= StartAttempts)
|
||||
{
|
||||
Stopped($"{options.EnginePath} failed to start after {attempts} tries");
|
||||
return;
|
||||
}
|
||||
attempts++;
|
||||
Reported?.Invoke(this, $"the engine failed to start, trying again ({attempts})");
|
||||
await OpenAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void Stopped(string why)
|
||||
{
|
||||
if (starting?.TrySetException(new InvalidOperationException(why)) == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (IsConnected)
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionChanged?.Invoke(this, false);
|
||||
}
|
||||
Reported?.Invoke(this, why);
|
||||
}
|
||||
|
||||
private static string Field(string[] fields, int index) =>
|
||||
index < fields.Length ? fields[index] : "";
|
||||
|
||||
private static int Number(string[] fields, int index) =>
|
||||
int.TryParse(Field(fields, index), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
17
src/Nonemm.Digital/MmttyMessage.cs
Normal file
17
src/Nonemm.Digital/MmttyMessage.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// What can be posted to MMTTY, from N1MM's use of the OCX. Message 0 hands the
|
||||
/// engine the host window it reports back to, and the bridge posts that itself
|
||||
/// as part of starting up.
|
||||
public enum MmttyMessage
|
||||
{
|
||||
Shutdown = 2,
|
||||
TypeCharacter = 4,
|
||||
Rate = 8,
|
||||
MarkFrequency = 9,
|
||||
SpaceFrequency = 10,
|
||||
Switches = 11,
|
||||
DefaultProfile = 12,
|
||||
Setup = 13,
|
||||
Profile = 24,
|
||||
}
|
||||
66
src/Nonemm.Digital/MmttyOptions.cs
Normal file
66
src/Nonemm.Digital/MmttyOptions.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// MMTTY's four window sizes. The letter is the command line switch the engine
|
||||
/// is started with.
|
||||
public enum EngineWindow
|
||||
{
|
||||
Normal,
|
||||
Small,
|
||||
Medium1,
|
||||
Medium2,
|
||||
}
|
||||
|
||||
/// What the engine needs to start: which program, which window, and which
|
||||
/// serial port keys it. The path is a Linux path; the bridge is given the
|
||||
/// Windows form of it.
|
||||
public sealed record MmttyOptions
|
||||
{
|
||||
public required string EnginePath { get; init; }
|
||||
|
||||
/// 1 or 2. A two-radio station runs an engine for each, and the second one
|
||||
/// must not answer to the first one's window title.
|
||||
public int Number { get; init; } = 1;
|
||||
|
||||
public EngineWindow Window { get; init; } = EngineWindow.Normal;
|
||||
|
||||
public bool OnTop { get; init; } = true;
|
||||
|
||||
/// The port MMTTY keys FSK and PTT on. Left null, it is read from Mmtty.INI
|
||||
/// beside the program, which is where MMTTY itself keeps it.
|
||||
public string? PttPort { get; init; }
|
||||
|
||||
/// 2Tone names its own window and takes none of MMTTY's switches except the
|
||||
/// window size.
|
||||
public bool IsTwoTone =>
|
||||
Path.GetFileName(EnginePath).Equals("2tone.exe", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// What the OCX is told to run. The engine has to be quoted: it usually
|
||||
/// sits under Program Files.
|
||||
public string CommandLine(string windowsEnginePath)
|
||||
{
|
||||
string line = $"\"{windowsEnginePath}\"{WindowSwitch()}";
|
||||
if (!OnTop)
|
||||
{
|
||||
line += " -a";
|
||||
}
|
||||
return IsTwoTone ? line : line + " -Z";
|
||||
}
|
||||
|
||||
/// The title the OCX gives the engine window. 2Tone titles its own window
|
||||
/// and ignores this.
|
||||
public string Title => $"RTTY Engine {Number}";
|
||||
|
||||
/// The window to look for once the engine is up.
|
||||
public string WindowTitle => IsTwoTone ? $"DI{Number} G3YYD 2Tone" : Title;
|
||||
|
||||
public string SettingsPath =>
|
||||
Path.Combine(Path.GetDirectoryName(EnginePath) ?? ".", "Mmtty.INI");
|
||||
|
||||
private string WindowSwitch() => Window switch
|
||||
{
|
||||
EngineWindow.Small => " -t",
|
||||
EngineWindow.Medium1 => " -s",
|
||||
EngineWindow.Medium2 => " -u",
|
||||
_ => " -r",
|
||||
};
|
||||
}
|
||||
63
src/Nonemm.Digital/MmttySettings.cs
Normal file
63
src/Nonemm.Digital/MmttySettings.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// The part of Mmtty.INI that the logger has to agree with. MMTTY keeps AFC,
|
||||
/// net and reverse in the file and reports them back as switch bits, so the
|
||||
/// starting state has to be read from the same place or the first toggle moves
|
||||
/// the wrong way.
|
||||
public sealed record MmttySettings
|
||||
{
|
||||
public const int AfcBit = 4;
|
||||
public const int NetBit = 8;
|
||||
public const int ReverseBit = 256;
|
||||
|
||||
public string PttPort { get; init; } = "";
|
||||
|
||||
public bool IsAfcOn { get; init; }
|
||||
|
||||
public bool IsNetOn { get; init; }
|
||||
|
||||
public bool IsReversed { get; init; }
|
||||
|
||||
/// The switch word MMTTY starts with.
|
||||
public int Switches =>
|
||||
(IsAfcOn ? AfcBit : 0) | (IsNetOn ? NetBit : 0) | (IsReversed ? ReverseBit : 0);
|
||||
|
||||
/// Reads the `[Define]` section. A file that is not there is not an error:
|
||||
/// MMTTY writes it when it first exits, so a new installation has none.
|
||||
public static MmttySettings Read(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new MmttySettings();
|
||||
}
|
||||
Dictionary<string, string> define = ReadSection(path, "Define");
|
||||
return new MmttySettings
|
||||
{
|
||||
PttPort = define.GetValueOrDefault("PTT", ""),
|
||||
IsAfcOn = define.GetValueOrDefault("AFC") == "1",
|
||||
IsNetOn = define.GetValueOrDefault("TxNet") == "1",
|
||||
IsReversed = define.GetValueOrDefault("Rev") == "1",
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadSection(string path, string section)
|
||||
{
|
||||
Dictionary<string, string> values = new(StringComparer.OrdinalIgnoreCase);
|
||||
bool inSection = false;
|
||||
foreach (string line in File.ReadLines(path))
|
||||
{
|
||||
string text = line.Trim();
|
||||
if (text.StartsWith('[') && text.EndsWith(']'))
|
||||
{
|
||||
inSection = text[1..^1].Equals(section, StringComparison.OrdinalIgnoreCase);
|
||||
continue;
|
||||
}
|
||||
int equals = text.IndexOf('=');
|
||||
if (inSection && equals > 0)
|
||||
{
|
||||
values[text[..equals].Trim()] = text[(equals + 1)..].Trim();
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
13
src/Nonemm.Digital/Nonemm.Digital.csproj
Normal file
13
src/Nonemm.Digital/Nonemm.Digital.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Nonemm.Keying\Nonemm.Keying.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
144
src/Nonemm.Digital/WineBridgeChannel.cs
Normal file
144
src/Nonemm.Digital/WineBridgeChannel.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// Runs the bridge under Wine and talks to it over its standard input and
|
||||
/// output. Wine writes its own diagnostics to standard error, so the protocol
|
||||
/// has standard output to itself.
|
||||
public sealed class WineBridgeChannel : BridgeChannel
|
||||
{
|
||||
private const int KeptErrorLines = 20;
|
||||
|
||||
private readonly string wine;
|
||||
private readonly string bridgePath;
|
||||
private readonly string? prefix;
|
||||
private readonly Queue<string> lastErrors = new();
|
||||
private readonly SemaphoreSlim writing = new(1, 1);
|
||||
private Process? bridge;
|
||||
|
||||
/// `prefix` is the WINEPREFIX to run in. Left null, Wine uses its default,
|
||||
/// which is what a station with one prefix wants.
|
||||
public WineBridgeChannel(string bridgePath, string? prefix = null, string wine = "wine")
|
||||
{
|
||||
this.bridgePath = bridgePath;
|
||||
this.prefix = prefix;
|
||||
this.wine = wine;
|
||||
}
|
||||
|
||||
public event EventHandler<string>? LineReceived;
|
||||
|
||||
public event EventHandler<string>? Failed;
|
||||
|
||||
public Task StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
bridge = Start(bridgePath);
|
||||
_ = ReadOutputAsync(bridge);
|
||||
_ = ReadErrorsAsync(bridge);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task SendAsync(string line, CancellationToken cancellation = default)
|
||||
{
|
||||
Process running = bridge ?? throw new InvalidOperationException("the bridge is not started");
|
||||
await writing.WaitAsync(cancellation).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await running.StandardInput.WriteLineAsync(line.AsMemory(), cancellation)
|
||||
.ConfigureAwait(false);
|
||||
await running.StandardInput.FlushAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
writing.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> ToWindowsPathAsync(
|
||||
string path,
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
using Process winepath = Start("winepath", "-w", path);
|
||||
string converted = await winepath.StandardOutput.ReadToEndAsync(cancellation)
|
||||
.ConfigureAwait(false);
|
||||
await winepath.WaitForExitAsync(cancellation).ConfigureAwait(false);
|
||||
if (winepath.ExitCode != 0 || converted.Trim().Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"winepath -w {path} failed with exit code {winepath.ExitCode}");
|
||||
}
|
||||
return converted.Trim();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (bridge is { HasExited: false })
|
||||
{
|
||||
bridge.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// the bridge stopped on its own, so there is nothing left to kill
|
||||
}
|
||||
bridge?.Dispose();
|
||||
writing.Dispose();
|
||||
}
|
||||
|
||||
private Process Start(string program, params string[] arguments)
|
||||
{
|
||||
ProcessStartInfo start = new()
|
||||
{
|
||||
FileName = wine,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
start.ArgumentList.Add(program);
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
if (prefix is not null)
|
||||
{
|
||||
start.Environment["WINEPREFIX"] = prefix;
|
||||
}
|
||||
return Process.Start(start)
|
||||
?? throw new InvalidOperationException($"{wine} {program} did not start");
|
||||
}
|
||||
|
||||
private async Task ReadOutputAsync(Process running)
|
||||
{
|
||||
while (await running.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line)
|
||||
{
|
||||
LineReceived?.Invoke(this, line);
|
||||
}
|
||||
await running.WaitForExitAsync().ConfigureAwait(false);
|
||||
Failed?.Invoke(this, $"the bridge exited with code {running.ExitCode}: {ErrorTail()}");
|
||||
}
|
||||
|
||||
private async Task ReadErrorsAsync(Process running)
|
||||
{
|
||||
while (await running.StandardError.ReadLineAsync().ConfigureAwait(false) is { } line)
|
||||
{
|
||||
lock (lastErrors)
|
||||
{
|
||||
lastErrors.Enqueue(line);
|
||||
if (lastErrors.Count > KeptErrorLines)
|
||||
{
|
||||
lastErrors.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string ErrorTail()
|
||||
{
|
||||
lock (lastErrors)
|
||||
{
|
||||
return string.Join(" / ", lastErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/Nonemm.Digital/WinePrefixSetup.cs
Normal file
106
src/Nonemm.Digital/WinePrefixSetup.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// Puts XMMT.ocx into a Wine prefix and registers it, which is what the bridge
|
||||
/// needs before it can create the control. The steps are the ones in
|
||||
/// docs/digital-bridge.md: make the prefix if it is not there, copy the control
|
||||
/// into the Windows folder, run regsvr32.
|
||||
public sealed class WinePrefixSetup
|
||||
{
|
||||
private readonly string? prefix;
|
||||
private readonly string wine;
|
||||
|
||||
/// `prefix` is the WINEPREFIX to set up. Left null, Wine uses its default.
|
||||
public WinePrefixSetup(string? prefix = null, string wine = "wine")
|
||||
{
|
||||
this.prefix = prefix;
|
||||
this.wine = wine;
|
||||
}
|
||||
|
||||
/// Where a 32-bit control goes. A 64-bit prefix keeps 32-bit code in
|
||||
/// syswow64 and has a second regsvr32 there to register it with; the
|
||||
/// 64-bit one cannot load the control at all.
|
||||
public static (string Folder, string Regsvr32) Places(bool isSixtyFourBit) =>
|
||||
isSixtyFourBit
|
||||
? (@"C:\windows\syswow64", @"C:\windows\syswow64\regsvr32.exe")
|
||||
: (@"C:\windows\system32", @"C:\windows\system32\regsvr32.exe");
|
||||
|
||||
/// Copies the control in and registers it. It returns what to show the
|
||||
/// operator, and throws when a step fails.
|
||||
public async Task<string> RegisterAsync(
|
||||
string controlPath,
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
if (!File.Exists(controlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"{controlPath} is not there", controlPath);
|
||||
}
|
||||
if (prefix is not null && !Directory.Exists(prefix))
|
||||
{
|
||||
await RunAsync("wineboot", ["-u"], cancellation, creating: true).ConfigureAwait(false);
|
||||
}
|
||||
string windows = await ToLinuxPathAsync(@"C:\windows", cancellation).ConfigureAwait(false);
|
||||
(string folder, string regsvr32) = Places(Directory.Exists(Path.Combine(windows, "syswow64")));
|
||||
string target = Path.Combine(
|
||||
await ToLinuxPathAsync(folder, cancellation).ConfigureAwait(false),
|
||||
"XMMT.ocx");
|
||||
File.Copy(controlPath, target, overwrite: true);
|
||||
await RunAsync(regsvr32, [$@"{folder}\XMMT.ocx"], cancellation).ConfigureAwait(false);
|
||||
return $"XMMT.ocx registered in {folder}";
|
||||
}
|
||||
|
||||
private async Task<string> ToLinuxPathAsync(string path, CancellationToken cancellation)
|
||||
{
|
||||
string converted = await RunAsync("winepath", ["-u", path], cancellation)
|
||||
.ConfigureAwait(false);
|
||||
return converted.Trim().Length > 0
|
||||
? converted.Trim()
|
||||
: throw new InvalidOperationException($"winepath -u {path} said nothing");
|
||||
}
|
||||
|
||||
/// Wine writes its own diagnostics to standard error and says nothing on
|
||||
/// standard output unless the program does, so a failure is reported with
|
||||
/// the error text.
|
||||
private async Task<string> RunAsync(
|
||||
string program,
|
||||
string[] arguments,
|
||||
CancellationToken cancellation,
|
||||
bool creating = false)
|
||||
{
|
||||
ProcessStartInfo start = new()
|
||||
{
|
||||
FileName = wine,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
start.ArgumentList.Add(program);
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
if (prefix is not null)
|
||||
{
|
||||
start.Environment["WINEPREFIX"] = prefix;
|
||||
}
|
||||
if (creating)
|
||||
{
|
||||
// only on a prefix that is being made: Wine stops if it is set on
|
||||
// a 64-bit prefix that is already there
|
||||
start.Environment["WINEARCH"] = "win32";
|
||||
}
|
||||
using Process running = Process.Start(start)
|
||||
?? throw new InvalidOperationException($"{wine} {program} did not start");
|
||||
Task<string> output = running.StandardOutput.ReadToEndAsync(cancellation);
|
||||
Task<string> errors = running.StandardError.ReadToEndAsync(cancellation);
|
||||
await running.WaitForExitAsync(cancellation).ConfigureAwait(false);
|
||||
if (running.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{program} {string.Join(' ', arguments)} failed with exit code "
|
||||
+ $"{running.ExitCode}: {(await errors.ConfigureAwait(false)).Trim()}");
|
||||
}
|
||||
return await output.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,29 @@
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// The order stacked calls come off, which is what N1MM's digital window calls
|
||||
/// its call stacking modes.
|
||||
public enum StackOrder
|
||||
{
|
||||
/// Multipliers first, then the order they arrived in.
|
||||
MultipliersFirst,
|
||||
|
||||
/// The order they arrived in.
|
||||
FirstIn,
|
||||
|
||||
/// Newest first.
|
||||
LastIn,
|
||||
|
||||
/// Nothing is stacked.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// The calls waiting to be worked while running. A station calling CQ hears
|
||||
/// more than one answer, and this holds the ones it has not got to yet, so the
|
||||
/// operator takes them one at a time instead of asking each to call again.
|
||||
/// N1MM's single-operator call stacking.
|
||||
///
|
||||
/// A call that would bring a multiplier goes to the front and the rest to the
|
||||
/// back, which is the order N1MM uses on CW and phone. N1MM's digital window
|
||||
/// offers plain first-in-first-out and last-in-first-out as well; there is no
|
||||
/// digital window here, so every mode gets that one order.
|
||||
/// The order calls come off is `Order`: multipliers first is what N1MM uses on
|
||||
/// CW and phone, and the digital window offers the other three.
|
||||
public sealed class CallStack
|
||||
{
|
||||
/// N1MM stacks nothing shorter than this.
|
||||
@@ -16,6 +31,10 @@ public sealed class CallStack
|
||||
|
||||
private readonly List<string> calls = [];
|
||||
|
||||
/// How calls come off. The digital window sets this from its call stacking
|
||||
/// menu; everything else leaves it alone.
|
||||
public StackOrder Order { get; set; } = StackOrder.MultipliersFirst;
|
||||
|
||||
/// The calls in the order they come off, so the front is next.
|
||||
public IReadOnlyList<string> Calls => calls;
|
||||
|
||||
@@ -27,16 +46,22 @@ public sealed class CallStack
|
||||
public string Top => calls.Count > 0 ? calls[0] : "";
|
||||
|
||||
/// Puts a call on the stack. A call already there is moved rather than
|
||||
/// repeated, and a multiplier goes to the front.
|
||||
/// repeated, and where it goes is up to `Order`.
|
||||
public void Add(string call, bool isMultiplier)
|
||||
{
|
||||
string wanted = call.Trim().ToUpperInvariant();
|
||||
if (wanted.Length < ShortestCall)
|
||||
if (wanted.Length < ShortestCall || Order == StackOrder.Disabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Remove(wanted);
|
||||
if (isMultiplier)
|
||||
bool toFront = Order switch
|
||||
{
|
||||
StackOrder.LastIn => true,
|
||||
StackOrder.MultipliersFirst => isMultiplier,
|
||||
_ => false,
|
||||
};
|
||||
if (toFront)
|
||||
{
|
||||
calls.Insert(0, wanted);
|
||||
}
|
||||
|
||||
115
src/Nonemm.Session/DigitalElement.cs
Normal file
115
src/Nonemm.Session/DigitalElement.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using Nonemm.Contests;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// What a word clicked in the receive pane is worth. N1MM drops a clicked word
|
||||
/// into whichever exchange box has the cursor. That box still comes first here
|
||||
/// when it takes the word, but a word the cursor's box does not take goes to
|
||||
/// the box of the contest's exchange that holds that kind of value, so a serial
|
||||
/// number and a state land where they belong.
|
||||
public static class DigitalElement
|
||||
{
|
||||
private const string Precedences = "QABUMS";
|
||||
|
||||
/// Strips what RTTY carries around a value: the report, a colon, and the Z
|
||||
/// on a four-figure time. N1MM cuts the same three before it takes an
|
||||
/// element from a click.
|
||||
public static string Trim(string word)
|
||||
{
|
||||
string text = DigitalReceiver.Scrub(word).Replace(":", "");
|
||||
if (text.Length > 3 && text.Contains("599"))
|
||||
{
|
||||
text = text.Replace("599", "");
|
||||
}
|
||||
if (text.Length == 5 && text[^1] == 'Z' && text[..4].All(char.IsAsciiDigit))
|
||||
{
|
||||
text = text[..4];
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/// True when the box holds this kind of value. A report box is never a
|
||||
/// target: nobody clicks 599 in, and the program fills it in itself.
|
||||
public static bool Fits(ExchangeField field, string word) => field.Kind switch
|
||||
{
|
||||
ExchangeFieldKind.Report => false,
|
||||
ExchangeFieldKind.Number => IsNumber(word, 1, 6),
|
||||
ExchangeFieldKind.CqZone => IsNumber(word, 1, 2) && InRange(word, 1, 40),
|
||||
ExchangeFieldKind.ItuZone => IsNumber(word, 1, 2) && InRange(word, 1, 90),
|
||||
ExchangeFieldKind.Check => IsNumber(word, 2, 2),
|
||||
ExchangeFieldKind.Power => IsPower(word),
|
||||
ExchangeFieldKind.Precedence => word.Length == 1 && Precedences.Contains(word[0]),
|
||||
ExchangeFieldKind.Grid => IsGrid(word),
|
||||
ExchangeFieldKind.Text => word.Length > 0,
|
||||
_ => ExchangeValues.For(field.Kind).Contains(word, StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
|
||||
/// The exchange box the word belongs in, counted the way `EntryFields`
|
||||
/// counts: 0 is the callsign box, 1 and up are the exchange boxes. Null
|
||||
/// when the contest has no box that takes it.
|
||||
public static int? FieldFor(EntryFields fields, string word)
|
||||
{
|
||||
if (word.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// the box the cursor is in wins when it takes the word: that is where
|
||||
// N1MM puts it, and the operator has already said which box it is
|
||||
if (fields.Focus > 0 && Fits(fields.Exchange[fields.Focus - 1], word))
|
||||
{
|
||||
return fields.Focus;
|
||||
}
|
||||
// then the box that only holds this kind of value, then a number box,
|
||||
// then plain text, so a check does not land in the serial number box
|
||||
// and a section does not land in the name box
|
||||
return Pick(fields, word, ListedOrShaped)
|
||||
?? Pick(fields, word, kind => kind == ExchangeFieldKind.Number)
|
||||
?? Pick(fields, word, kind => kind == ExchangeFieldKind.Text);
|
||||
}
|
||||
|
||||
private static bool ListedOrShaped(ExchangeFieldKind kind) =>
|
||||
kind is not (ExchangeFieldKind.Number or ExchangeFieldKind.Text or ExchangeFieldKind.Report);
|
||||
|
||||
private static int? Pick(EntryFields fields, string word, Func<ExchangeFieldKind, bool> wanted)
|
||||
{
|
||||
int? filled = null;
|
||||
for (int at = 0; at < fields.Exchange.Count; at++)
|
||||
{
|
||||
ExchangeField field = fields.Exchange[at];
|
||||
if (!wanted(field.Kind) || !Fits(field, word))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (fields[at + 1].Trim().Length == 0)
|
||||
{
|
||||
return at + 1;
|
||||
}
|
||||
// every box that takes it is full, so the first one is written over
|
||||
filled ??= at + 1;
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
private static bool IsNumber(string word, int shortest, int longest) =>
|
||||
word.Length >= shortest && word.Length <= longest && word.All(char.IsAsciiDigit);
|
||||
|
||||
private static bool InRange(string word, int lowest, int highest) =>
|
||||
int.TryParse(word, out int value) && value >= lowest && value <= highest;
|
||||
|
||||
/// A power is the figures, with or without the W or KW after them.
|
||||
private static bool IsPower(string word)
|
||||
{
|
||||
string figures = word.EndsWith("KW", StringComparison.Ordinal)
|
||||
? word[..^2]
|
||||
: word.EndsWith('W') ? word[..^1] : word;
|
||||
return IsNumber(figures, 1, 4);
|
||||
}
|
||||
|
||||
/// Four or six characters: two letters, two figures, and a letter pair on a
|
||||
/// six-character locator.
|
||||
private static bool IsGrid(string word) =>
|
||||
word.Length is 4 or 6
|
||||
&& char.IsAsciiLetter(word[0]) && char.IsAsciiLetter(word[1])
|
||||
&& char.IsAsciiDigit(word[2]) && char.IsAsciiDigit(word[3])
|
||||
&& (word.Length == 4 || (char.IsAsciiLetter(word[4]) && char.IsAsciiLetter(word[5])));
|
||||
}
|
||||
44
src/Nonemm.Session/DigitalMacros.cs
Normal file
44
src/Nonemm.Session/DigitalMacros.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// The digital window's macro buttons: three rows of eight, each a label and a
|
||||
/// message, held as the same `label,message` lines as an N1MM function key
|
||||
/// file. N1MM keeps its digital macros in its admin database rather than in a
|
||||
/// file; the lines here say the same thing in the form the rest of this program
|
||||
/// already reads.
|
||||
public sealed class DigitalMacros
|
||||
{
|
||||
public const int ButtonCount = 24;
|
||||
|
||||
private readonly IReadOnlyList<FunctionKey> buttons;
|
||||
|
||||
private DigitalMacros(IReadOnlyList<FunctionKey> buttons)
|
||||
{
|
||||
this.buttons = buttons;
|
||||
}
|
||||
|
||||
public IReadOnlyList<FunctionKey> Buttons => buttons;
|
||||
|
||||
public FunctionKey this[int index] =>
|
||||
index >= 0 && index < buttons.Count ? buttons[index] : FunctionKey.Empty;
|
||||
|
||||
public static DigitalMacros Parse(string text)
|
||||
{
|
||||
List<FunctionKey> found = [];
|
||||
foreach (string line in text.Replace("\r", "").Split('\n'))
|
||||
{
|
||||
if (line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int comma = line.IndexOf(',');
|
||||
found.Add(comma < 0
|
||||
? new FunctionKey("", line.Trim())
|
||||
: new FunctionKey(line[..comma].Replace("&&", "&").Trim(), line[(comma + 1)..].Trim()));
|
||||
}
|
||||
while (found.Count < ButtonCount)
|
||||
{
|
||||
found.Add(FunctionKey.Empty);
|
||||
}
|
||||
return new DigitalMacros(found.Take(ButtonCount).ToList());
|
||||
}
|
||||
}
|
||||
125
src/Nonemm.Session/DigitalReceiver.cs
Normal file
125
src/Nonemm.Session/DigitalReceiver.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using System.Text;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// The decoded text as it arrives, and the callsigns in it. Characters come
|
||||
/// from the modem one at a time; a word ends at a space or a line break, and a
|
||||
/// word shaped like a callsign is reported so the window can colour it and put
|
||||
/// it in the grab list. N1MM's `DigitalProcessing.RxComData` does the same.
|
||||
public sealed class DigitalReceiver
|
||||
{
|
||||
/// How much decoded text is kept. N1MM keeps a scrollback of its own; this
|
||||
/// is enough for a long run and keeps the pane from growing without end.
|
||||
public const int Scrollback = 20_000;
|
||||
|
||||
private readonly StringBuilder text = new();
|
||||
private readonly StringBuilder word = new();
|
||||
|
||||
public string Text => text.ToString();
|
||||
|
||||
/// Text that has just arrived, for a window that appends rather than
|
||||
/// redraws.
|
||||
public event EventHandler<string>? Added;
|
||||
|
||||
/// A word shaped like a callsign, uppercased and cut back to the call.
|
||||
public event EventHandler<string>? CallHeard;
|
||||
|
||||
public void Receive(string arrived)
|
||||
{
|
||||
if (arrived.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
text.Append(arrived);
|
||||
if (text.Length > Scrollback)
|
||||
{
|
||||
text.Remove(0, text.Length - Scrollback);
|
||||
}
|
||||
Added?.Invoke(this, arrived);
|
||||
foreach (char c in arrived)
|
||||
{
|
||||
if (c is ' ' or '\r' or '\n')
|
||||
{
|
||||
Finish();
|
||||
continue;
|
||||
}
|
||||
word.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
text.Clear();
|
||||
word.Clear();
|
||||
}
|
||||
|
||||
/// The word around a position in the text, which is what the mouse points
|
||||
/// at. Empty when the position is on a space.
|
||||
public static string WordAt(string text, int position)
|
||||
{
|
||||
if (position < 0 || position >= text.Length || IsBreak(text[position]))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
int start = position;
|
||||
while (start > 0 && !IsBreak(text[start - 1]))
|
||||
{
|
||||
start--;
|
||||
}
|
||||
int end = position;
|
||||
while (end + 1 < text.Length && !IsBreak(text[end + 1]))
|
||||
{
|
||||
end++;
|
||||
}
|
||||
return Scrub(text[start..(end + 1)]);
|
||||
}
|
||||
|
||||
/// N1MM's `ScrubSelectedData`: what the operator clicked, without the line
|
||||
/// breaks and in upper case.
|
||||
public static string Scrub(string word) =>
|
||||
word.Replace("\r", "").Replace("\n", "").Trim().ToUpperInvariant();
|
||||
|
||||
/// True when the word could be a callsign. RTTY prints noise, so a word is
|
||||
/// only offered as a call when it is shaped like one.
|
||||
public static bool IsCallsign(string word) =>
|
||||
word.Length > 0 && Callsign.Parse(word).IsPlausible;
|
||||
|
||||
/// N1MM's `Trimcall`. RTTY runs words together, so a long word with a digit
|
||||
/// in it is cut to three characters past the last digit, which is as long
|
||||
/// as a callsign gets after its number. A word with a `/` is left alone: a
|
||||
/// portable call is long for a reason.
|
||||
public static string TrimToCall(string word)
|
||||
{
|
||||
if (word.Contains('/') || word.Length < 7)
|
||||
{
|
||||
return word;
|
||||
}
|
||||
int lastDigit = -1;
|
||||
for (int at = 0; at < word.Length; at++)
|
||||
{
|
||||
if (char.IsAsciiDigit(word[at]))
|
||||
{
|
||||
lastDigit = at;
|
||||
}
|
||||
}
|
||||
return lastDigit < 0 ? word : word[..Math.Min(word.Length, lastDigit + 4)];
|
||||
}
|
||||
|
||||
private static bool IsBreak(char c) => c is ' ' or '\r' or '\n' or '\t';
|
||||
|
||||
private void Finish()
|
||||
{
|
||||
string found = Scrub(word.ToString());
|
||||
word.Clear();
|
||||
if (found.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string call = TrimToCall(found);
|
||||
if (IsCallsign(call))
|
||||
{
|
||||
CallHeard?.Invoke(this, call);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/Nonemm.Session/GrabList.cs
Normal file
72
src/Nonemm.Session/GrabList.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// Which calls the grab list keeps.
|
||||
public enum GrabFilter
|
||||
{
|
||||
/// Every call heard.
|
||||
Everything,
|
||||
|
||||
/// Nothing already in the log on this band and mode. N1MM's "no dupes".
|
||||
NotDupes,
|
||||
|
||||
/// Only calls the callsign database knows or the log has seen before,
|
||||
/// which throws away most of what noise decodes as. N1MM's "master and
|
||||
/// previous calls".
|
||||
KnownCalls,
|
||||
}
|
||||
|
||||
/// The callsigns heard on the air, newest first, waiting to be grabbed into
|
||||
/// the entry window. N1MM's `Calltext` list beside the digital window's macro
|
||||
/// buttons.
|
||||
///
|
||||
/// What is left out is N1MM's: our own call, the call already being typed, a
|
||||
/// call that is only part of our own, and — depending on the filter — dupes and
|
||||
/// calls nothing has ever heard of.
|
||||
public sealed class GrabList
|
||||
{
|
||||
private readonly List<string> calls = [];
|
||||
|
||||
public GrabList(int capacity = 30)
|
||||
{
|
||||
Capacity = capacity;
|
||||
}
|
||||
|
||||
/// How many calls are kept. The oldest falls off the end.
|
||||
public int Capacity { get; set; }
|
||||
|
||||
public bool NewestFirst { get; set; } = true;
|
||||
|
||||
public IReadOnlyList<string> Calls => calls;
|
||||
|
||||
public int Count => calls.Count;
|
||||
|
||||
/// Puts a call in the list, or moves it if it is already there. False when
|
||||
/// the call was not wanted.
|
||||
public bool Add(string call)
|
||||
{
|
||||
string wanted = call.Trim().ToUpperInvariant();
|
||||
if (wanted.Length < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
calls.Remove(wanted);
|
||||
calls.Insert(NewestFirst ? 0 : calls.Count, wanted);
|
||||
while (calls.Count > Capacity)
|
||||
{
|
||||
calls.RemoveAt(NewestFirst ? calls.Count - 1 : 0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Remove(string call) =>
|
||||
calls.RemoveAll(c => string.Equals(c, call.Trim(), StringComparison.OrdinalIgnoreCase)) > 0;
|
||||
|
||||
public void Clear() => calls.Clear();
|
||||
|
||||
/// Whether a heard call belongs in the list at all.
|
||||
public static bool Wanted(string call, string myCall, string beingTyped) =>
|
||||
call.Length >= 3
|
||||
&& !string.Equals(call, myCall, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(call, beingTyped.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||
&& !(myCall.Length > 0 && myCall.Contains(call, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -66,4 +66,11 @@ public enum MessageCommand
|
||||
|
||||
/// Empties the call stack.
|
||||
ClearStack,
|
||||
|
||||
/// N1MM's `{TX}`: the digital engine starts transmitting.
|
||||
StartTransmit,
|
||||
|
||||
/// N1MM's `{RX}`: the engine goes back to receive once the buffer is
|
||||
/// empty.
|
||||
ReturnToReceive,
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ public static class MessageExpander
|
||||
"RRMHZ" => Megahertz(session, Radio(session, other, 2)),
|
||||
"TIMESTAMP" => DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
||||
"TIME2" => DateTime.UtcNow.ToString("HHmm", CultureInfo.InvariantCulture),
|
||||
// the digital window's carriage return, which starts a new line on
|
||||
// the other station's screen
|
||||
"ENTER" => "\r",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ public sealed record MessagePlan(
|
||||
// N1MM runs the same code for both
|
||||
"LOGTHENPOP" or "LOGTHENNEXT" => new MessageAction(MessageCommand.LogThenPop),
|
||||
"CLRSTACK" => new MessageAction(MessageCommand.ClearStack),
|
||||
"TX" => new MessageAction(MessageCommand.StartTransmit),
|
||||
"RX" => new MessageAction(MessageCommand.ReturnToReceive),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
22
src/Nonemm.Session/ModeEntry.cs
Normal file
22
src/Nonemm.Session/ModeEntry.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// Reads a mode typed into the callsign box, which is how an operator changes
|
||||
/// mode without touching a radio. N1MM takes a mode there the same way it takes
|
||||
/// a frequency, and it is the only way to reach a mode with no radio connected.
|
||||
public static class ModeEntry
|
||||
{
|
||||
/// Null when the text names no mode. `SSB`, `PH` and `PHONE` take the
|
||||
/// sideband the frequency calls for, which is what the band panel does.
|
||||
public static Mode? Parse(string text, Frequency where)
|
||||
{
|
||||
string wanted = text.Trim().ToUpperInvariant();
|
||||
return wanted switch
|
||||
{
|
||||
"" => null,
|
||||
"SSB" or "PH" or "PHONE" => Modes.ForSideband(where),
|
||||
_ => Modes.Parse(wanted),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,9 @@ public sealed class OperatingPosition
|
||||
/// The frequency typed into the callsign box, if that is what it holds.
|
||||
public Frequency? PendingQsy() => FrequencyEntry.Parse(Entry.Call);
|
||||
|
||||
/// The mode typed into the callsign box, if that is what it holds.
|
||||
public Mode? PendingMode() => ModeEntry.Parse(Entry.Call, Frequency);
|
||||
|
||||
/// What working this call on the band and mode we are on would bring,
|
||||
/// judged from the call alone. A call that has not been worked has sent no
|
||||
/// exchange, so a contest scored on what the other station sends can be
|
||||
|
||||
Reference in New Issue
Block a user