Add CW keying, function key messages and the documentation
The function keys send through cwdaemon or a WinKeyer, with N1MM's message macros. Escape stops sending. Settings are read with the reflection serializer rather than a generated one: the generated one hands back null for every property the file leaves out instead of the value the property is declared with, which crashed the program the first time a new setting was added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Core.Country;
|
||||
using Nonemm.Keying;
|
||||
using Nonemm.Network;
|
||||
using Nonemm.Rig;
|
||||
using Nonemm.Session;
|
||||
@@ -20,6 +21,7 @@ public sealed class AppSession : IDisposable
|
||||
private RigctldRadio? radio;
|
||||
private ClusterClient? cluster;
|
||||
private StationNetwork? network;
|
||||
private MessageSender? keyer;
|
||||
|
||||
public AppSession(UserPaths paths, Settings settings)
|
||||
{
|
||||
@@ -56,6 +58,8 @@ public sealed class AppSession : IDisposable
|
||||
|
||||
public StationNetwork? Network => network;
|
||||
|
||||
public MessageSender? Keyer => keyer;
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public event EventHandler? ContestChanged;
|
||||
@@ -151,6 +155,31 @@ public sealed class AppSession : IDisposable
|
||||
OpenContest(Logging.Instance.ContestNumber);
|
||||
}
|
||||
|
||||
/// Starts, restarts or stops the keyer, following what the settings say.
|
||||
/// A keyer that will not open is reported; the program keeps running
|
||||
/// without one.
|
||||
public void ApplyKeyerSettings()
|
||||
{
|
||||
keyer?.Dispose();
|
||||
keyer = null;
|
||||
switch (Settings.KeyerKind.ToLowerInvariant())
|
||||
{
|
||||
case "cwdaemon":
|
||||
keyer = new CwDaemonSender(Settings.KeyerHost, Settings.KeyerPort);
|
||||
break;
|
||||
case "winkeyer":
|
||||
WinkeyerSender winkeyer = new(Settings.KeyerSerialPort);
|
||||
winkeyer.Open();
|
||||
keyer = winkeyer;
|
||||
break;
|
||||
}
|
||||
if (keyer is not null)
|
||||
{
|
||||
_ = keyer.SetSpeedAsync(Settings.KeyerSpeed);
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void ConnectRadio()
|
||||
{
|
||||
radio?.Dispose();
|
||||
@@ -204,6 +233,7 @@ public sealed class AppSession : IDisposable
|
||||
radio?.Dispose();
|
||||
cluster?.Dispose();
|
||||
network?.Dispose();
|
||||
keyer?.Dispose();
|
||||
store?.Dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
@@ -35,6 +34,27 @@ public sealed record Settings
|
||||
|
||||
public IReadOnlyList<string> NetworkPeers { get; init; } = [];
|
||||
|
||||
/// `none`, `cwdaemon` or `winkeyer`.
|
||||
public string KeyerKind { get; init; } = "none";
|
||||
|
||||
public string KeyerHost { get; init; } = "127.0.0.1";
|
||||
|
||||
public int KeyerPort { get; init; } = 6789;
|
||||
|
||||
public string KeyerSerialPort { get; init; } = "";
|
||||
|
||||
public int KeyerSpeed { get; init; } = 28;
|
||||
|
||||
/// The twelve function key messages, keyed F1 to F12.
|
||||
public IReadOnlyList<string> CwMessages { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> PhoneMessages { get; init; } = [];
|
||||
|
||||
/// 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.
|
||||
private static readonly JsonSerializerOptions Json = new() { WriteIndented = true };
|
||||
|
||||
public static Settings Load(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
@@ -43,8 +63,7 @@ public sealed record Settings
|
||||
}
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(File.ReadAllText(path), SettingsJson.Default.Settings)
|
||||
?? new Settings();
|
||||
return JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), Json) ?? new Settings();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -55,7 +74,7 @@ public sealed record Settings
|
||||
}
|
||||
|
||||
public void Save(string path) =>
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(this, SettingsJson.Default.Settings));
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(this, Json));
|
||||
}
|
||||
|
||||
/// The operator's station as it is stored, kept separate from `StationInfo` so
|
||||
@@ -111,7 +130,3 @@ public sealed record StoredStation
|
||||
Precedence = Precedence,
|
||||
};
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(Settings))]
|
||||
[JsonSourceGenerationOptions(WriteIndented = true)]
|
||||
internal sealed partial class SettingsJson : JsonSerializerContext;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<StackPanel Margin="14" Spacing="6">
|
||||
<Grid ColumnDefinitions="*,8,110" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Text="Node" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
|
||||
<TextBox Name="HostBox" Grid.Row="1" Watermark="dxc.example.net" />
|
||||
<TextBox Name="HostBox" Grid.Row="1" PlaceholderText="dxc.example.net" />
|
||||
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
|
||||
<TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
|
||||
</Grid>
|
||||
|
||||
35
src/Nonemm.App/Dialogs/KeyerDialog.axaml
Normal file
35
src/Nonemm.App/Dialogs/KeyerDialog.axaml
Normal file
@@ -0,0 +1,35 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.KeyerDialog"
|
||||
Title="Keyer and messages" Width="560" Height="560"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<DockPanel Margin="14">
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="6">
|
||||
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||
Text="cwdaemon keys the radio from a serial or parallel port and is what Linux stations usually run. A WinKeyer is on a serial port of its own." />
|
||||
<Grid ColumnDefinitions="150,8,*,8,90" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Text="Keyer" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<ComboBox Name="KindBox" Grid.Row="1" HorizontalAlignment="Stretch" />
|
||||
<TextBlock Grid.Column="2" Name="TargetLabel" Text="cwdaemon host" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="TargetBox" Grid.Row="1" Grid.Column="2" />
|
||||
<TextBlock Grid.Column="4" Text="Speed" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="SpeedBox" Grid.Row="1" Grid.Column="4" />
|
||||
</Grid>
|
||||
<TextBlock Text="Messages" FontSize="11" Opacity="0.7" Margin="0,10,0,1" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<RadioButton Name="CwButton" Content="CW" GroupName="mode" IsChecked="True" />
|
||||
<RadioButton Name="PhoneButton" Content="Phone" GroupName="mode" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right"
|
||||
Spacing="6" Margin="0,10,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Margin="0,6,0,0">
|
||||
<Grid Name="MessageGrid" ColumnDefinitions="60,*" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
100
src/Nonemm.App/Dialogs/KeyerDialog.axaml.cs
Normal file
100
src/Nonemm.App/Dialogs/KeyerDialog.axaml.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Configuration;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
/// Which keyer to use, and what the function keys send.
|
||||
public sealed partial class KeyerDialog : Window
|
||||
{
|
||||
private readonly Settings settings;
|
||||
private readonly List<TextBox> messageBoxes = [];
|
||||
private List<string> cwMessages;
|
||||
private List<string> phoneMessages;
|
||||
|
||||
public KeyerDialog(Settings settings)
|
||||
{
|
||||
this.settings = settings;
|
||||
cwMessages = [.. Messages.For(Core.ModeCategory.Cw, settings.CwMessages, settings.PhoneMessages)];
|
||||
phoneMessages = [.. Messages.For(Core.ModeCategory.Phone, settings.CwMessages, settings.PhoneMessages)];
|
||||
InitializeComponent();
|
||||
KindBox.ItemsSource = new[] { "none", "cwdaemon", "winkeyer" };
|
||||
KindBox.SelectedItem = settings.KeyerKind;
|
||||
KindBox.SelectionChanged += (_, _) => ShowTarget();
|
||||
SpeedBox.Text = settings.KeyerSpeed.ToString();
|
||||
CwButton.IsCheckedChanged += (_, _) => ShowMessages();
|
||||
ShowTarget();
|
||||
BuildMessageBoxes();
|
||||
ShowMessages();
|
||||
}
|
||||
|
||||
private void ShowTarget()
|
||||
{
|
||||
bool winkeyer = (KindBox.SelectedItem as string) == "winkeyer";
|
||||
TargetLabel.Text = winkeyer ? "serial port" : "cwdaemon host:port";
|
||||
TargetBox.Text = winkeyer
|
||||
? settings.KeyerSerialPort
|
||||
: $"{settings.KeyerHost}:{settings.KeyerPort}";
|
||||
}
|
||||
|
||||
private void BuildMessageBoxes()
|
||||
{
|
||||
for (int at = 0; at < Messages.Keys.Count; at++)
|
||||
{
|
||||
MessageGrid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
TextBlock label = new()
|
||||
{
|
||||
Text = Messages.Keys[at].Key,
|
||||
FontSize = 12,
|
||||
Margin = new Avalonia.Thickness(0, 6, 6, 0),
|
||||
};
|
||||
Grid.SetRow(label, at);
|
||||
MessageGrid.Children.Add(label);
|
||||
|
||||
TextBox box = new() { Margin = new Avalonia.Thickness(0, 2, 0, 2) };
|
||||
Grid.SetRow(box, at);
|
||||
Grid.SetColumn(box, 1);
|
||||
MessageGrid.Children.Add(box);
|
||||
messageBoxes.Add(box);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowMessages()
|
||||
{
|
||||
List<string> shown = CwButton.IsChecked == true ? cwMessages : phoneMessages;
|
||||
for (int at = 0; at < messageBoxes.Count; at++)
|
||||
{
|
||||
messageBoxes[at].Text = shown[at];
|
||||
}
|
||||
}
|
||||
|
||||
private void KeepMessages()
|
||||
{
|
||||
List<string> target = CwButton.IsChecked == true ? cwMessages : phoneMessages;
|
||||
for (int at = 0; at < messageBoxes.Count; at++)
|
||||
{
|
||||
target[at] = messageBoxes[at].Text ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
KeepMessages();
|
||||
bool winkeyer = (KindBox.SelectedItem as string) == "winkeyer";
|
||||
string[] target = (TargetBox.Text ?? "").Split(':');
|
||||
Close(settings with
|
||||
{
|
||||
KeyerKind = KindBox.SelectedItem as string ?? "none",
|
||||
KeyerSerialPort = winkeyer ? (TargetBox.Text ?? "").Trim() : settings.KeyerSerialPort,
|
||||
KeyerHost = winkeyer ? settings.KeyerHost : target[0].Trim(),
|
||||
KeyerPort = !winkeyer && target.Length > 1 && int.TryParse(target[1], out int port)
|
||||
? port
|
||||
: settings.KeyerPort,
|
||||
KeyerSpeed = int.TryParse(SpeedBox.Text, out int speed) ? speed : settings.KeyerSpeed,
|
||||
CwMessages = cwMessages,
|
||||
PhoneMessages = phoneMessages,
|
||||
});
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
<TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
|
||||
</Grid>
|
||||
<TextBlock Text="Other stations, one address per line" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="PeersBox" AcceptsReturn="True" Height="90" Watermark="192.168.1.11" />
|
||||
<TextBox Name="PeersBox" AcceptsReturn="True" Height="90" PlaceholderText="192.168.1.11" />
|
||||
<CheckBox Name="EnabledBox" Content="Share contacts with the other stations" Margin="0,6,0,0" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
|
||||
@@ -1,22 +1,58 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.App;
|
||||
|
||||
/// The function key messages. The text uses N1MM's macro names so a message
|
||||
/// file written for either program means the same thing.
|
||||
/// written for either program says the same thing.
|
||||
public static class Messages
|
||||
{
|
||||
public static readonly IReadOnlyList<(string Key, string Label, string Text)> Defaults =
|
||||
public static readonly IReadOnlyList<(string Key, string Label)> Keys =
|
||||
[
|
||||
("F1", "CQ", "CQ TEST {MYCALL} {MYCALL}"),
|
||||
("F2", "Exch", "{SENTRST} {EXCH}"),
|
||||
("F3", "TU", "TU {MYCALL}"),
|
||||
("F4", "MyCall", "{MYCALL}"),
|
||||
("F5", "HisCall", "{CALL}"),
|
||||
("F6", "Repeat", "{EXCH} {EXCH}"),
|
||||
("F7", "?", "?"),
|
||||
("F8", "Agn", "AGN"),
|
||||
("F9", "Nr?", "NR?"),
|
||||
("F10", "Call?", "CALL?"),
|
||||
("F11", "Spot", ""),
|
||||
("F12", "Wipe", ""),
|
||||
("F1", "CQ"), ("F2", "Exch"), ("F3", "TU"), ("F4", "MyCall"),
|
||||
("F5", "HisCall"), ("F6", "Repeat"), ("F7", "?"), ("F8", "Agn"),
|
||||
("F9", "Nr?"), ("F10", "Call?"), ("F11", "Spot"), ("F12", "Wipe"),
|
||||
];
|
||||
|
||||
public static readonly IReadOnlyList<string> DefaultCw =
|
||||
[
|
||||
"CQ TEST {MYCALL} {MYCALL}",
|
||||
"{SENTRST} {EXCH}",
|
||||
"TU {MYCALL}",
|
||||
"{MYCALL}",
|
||||
"{CALL}",
|
||||
"{EXCH} {EXCH}",
|
||||
"?",
|
||||
"AGN",
|
||||
"NR?",
|
||||
"CALL?",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
|
||||
/// Phone messages name a recording to play; the text is what would be said.
|
||||
public static readonly IReadOnlyList<string> DefaultPhone =
|
||||
[
|
||||
"CQ CONTEST {MYCALL}",
|
||||
"{EXCH}",
|
||||
"THANK YOU {MYCALL}",
|
||||
"{MYCALL}",
|
||||
"{CALL}",
|
||||
"{EXCH} {EXCH}",
|
||||
"PLEASE REPEAT",
|
||||
"AGAIN",
|
||||
"NUMBER PLEASE",
|
||||
"YOUR CALL PLEASE",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
|
||||
public static IReadOnlyList<string> For(
|
||||
ModeCategory mode,
|
||||
IReadOnlyList<string> cw,
|
||||
IReadOnlyList<string> phone)
|
||||
{
|
||||
IReadOnlyList<string> stored = mode == ModeCategory.Phone ? phone : cw;
|
||||
IReadOnlyList<string> fallback = mode == ModeCategory.Phone ? DefaultPhone : DefaultCw;
|
||||
return stored.Count == Keys.Count ? stored : fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,5 +27,6 @@
|
||||
<ProjectReference Include="..\Nonemm.Spotting\Nonemm.Spotting.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Session\Nonemm.Session.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Network\Nonemm.Network.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Keying\Nonemm.Keying.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -254,6 +254,27 @@ public sealed partial class EntryWindow
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnKeyerSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
KeyerDialog dialog = new(session.Settings);
|
||||
Settings? updated = await dialog.ShowDialog<Settings?>(this);
|
||||
if (updated is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(updated);
|
||||
try
|
||||
{
|
||||
session.ApplyKeyerSettings();
|
||||
Status(updated.KeyerKind == "none" ? "no keyer" : $"keyer: {updated.KeyerKind}");
|
||||
}
|
||||
catch (Exception error) when (error is InvalidOperationException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Status($"could not open the keyer: {error.Message}");
|
||||
}
|
||||
BuildFunctionKeys();
|
||||
}
|
||||
|
||||
private async void OnDownloadCountryFile(object? sender, RoutedEventArgs e) =>
|
||||
await Download(
|
||||
downloader => downloader.DownloadCountryFileAsync(session.Paths.CountryFile),
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<MenuItem Header="_Radio…" Click="OnRadioSettings" />
|
||||
<MenuItem Header="_Cluster…" Click="OnClusterSettings" />
|
||||
<MenuItem Header="_Network…" Click="OnNetworkSettings" />
|
||||
<MenuItem Header="_Keyer and messages…" Click="OnKeyerSettings" />
|
||||
<Separator />
|
||||
<MenuItem Header="Download Country _File" Click="OnDownloadCountryFile" />
|
||||
<MenuItem Header="Download Check _Partial File" Click="OnDownloadCallDatabase" />
|
||||
|
||||
@@ -55,9 +55,47 @@ public sealed partial class EntryWindow : Window
|
||||
{
|
||||
Status($"could not reopen the last log: {e.Message}");
|
||||
}
|
||||
StartConnections();
|
||||
BuildEntryBoxes();
|
||||
}
|
||||
|
||||
/// Brings up whatever the operator had connected last time. Each is
|
||||
/// reported on its own so one that fails does not stop the others.
|
||||
private void StartConnections()
|
||||
{
|
||||
foreach ((string what, Action start) in Connections())
|
||||
{
|
||||
try
|
||||
{
|
||||
start();
|
||||
}
|
||||
catch (Exception e) when (e is InvalidOperationException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Status($"could not start the {what}: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<(string What, Action Start)> Connections()
|
||||
{
|
||||
if (session.Settings.RadioEnabled)
|
||||
{
|
||||
yield return ("radio", session.ConnectRadio);
|
||||
}
|
||||
if (session.Settings.ClusterEnabled)
|
||||
{
|
||||
yield return ("cluster", session.ConnectCluster);
|
||||
}
|
||||
if (session.Settings.NetworkEnabled)
|
||||
{
|
||||
yield return ("station network", session.ApplyNetworkSettings);
|
||||
}
|
||||
if (session.Settings.KeyerKind != "none")
|
||||
{
|
||||
yield return ("keyer", session.ApplyKeyerSettings);
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildEntryBoxes()
|
||||
{
|
||||
EntryGrid.Children.Clear();
|
||||
@@ -132,6 +170,7 @@ public sealed partial class EntryWindow : Window
|
||||
break;
|
||||
case Key.Escape:
|
||||
e.Handled = true;
|
||||
_ = session.Keyer?.AbortAsync();
|
||||
Logging.Wipe();
|
||||
SyncBoxes();
|
||||
boxes[0].Focus();
|
||||
@@ -144,6 +183,10 @@ public sealed partial class EntryWindow : Window
|
||||
e.Handled = true;
|
||||
ToggleRun();
|
||||
break;
|
||||
case >= Key.F1 and <= Key.F12:
|
||||
e.Handled = true;
|
||||
RunFunctionKey(e.Key - Key.F1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,11 +325,85 @@ public sealed partial class EntryWindow : Window
|
||||
private void BuildFunctionKeys()
|
||||
{
|
||||
FunctionKeys.Children.Clear();
|
||||
foreach ((string key, string label, _) in Messages.Defaults)
|
||||
for (int at = 0; at < Messages.Keys.Count; at++)
|
||||
{
|
||||
int index = at;
|
||||
(string key, string label) = Messages.Keys[at];
|
||||
Button button = new() { Content = $"{key} {label}" };
|
||||
button.Classes.Add("fkey");
|
||||
button.Click += (_, _) => RunFunctionKey(index);
|
||||
FunctionKeys.Children.Add(button);
|
||||
}
|
||||
}
|
||||
|
||||
/// F11 spots the station being worked, F12 wipes the entry, and the rest
|
||||
/// send their message.
|
||||
private void RunFunctionKey(int index)
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
switch (index)
|
||||
{
|
||||
case 10:
|
||||
SpotCurrentCall();
|
||||
return;
|
||||
case 11:
|
||||
Logging.Wipe();
|
||||
SyncBoxes();
|
||||
boxes[0].Focus();
|
||||
return;
|
||||
default:
|
||||
SendMessage(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void SendMessage(int index)
|
||||
{
|
||||
if (Logging is null || session.Keyer is null)
|
||||
{
|
||||
Status("no keyer — Config ▸ Keyer");
|
||||
return;
|
||||
}
|
||||
string template = Messages.For(
|
||||
Logging.Mode.Category,
|
||||
session.Settings.CwMessages,
|
||||
session.Settings.PhoneMessages)[index];
|
||||
if (template.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string text = MessageExpander.Expand(template, Logging);
|
||||
Status($"sending {text}");
|
||||
_ = SendAsync(text);
|
||||
}
|
||||
|
||||
private async Task SendAsync(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
await session.Keyer!.SendAsync(text);
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
Status(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void SpotCurrentCall()
|
||||
{
|
||||
if (Logging is null || Logging.Entry.Call.Trim().Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Bandmap.Add(new Nonemm.Spotting.Spot(
|
||||
Core.Callsign.Parse(Logging.Entry.Call.Trim()),
|
||||
Logging.Frequency,
|
||||
DateTime.UtcNow,
|
||||
Nonemm.Spotting.SpotSource.Operator,
|
||||
Logging.Me.Callsign));
|
||||
Status($"{Logging.Entry.Call.Trim()} put on the bandmap");
|
||||
}
|
||||
}
|
||||
|
||||
49
src/Nonemm.Keying/CwDaemonSender.cs
Normal file
49
src/Nonemm.Keying/CwDaemonSender.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace Nonemm.Keying;
|
||||
|
||||
/// Sends CW through `cwdaemon`, which keys the radio from a serial or parallel
|
||||
/// port and is what Linux stations usually run. Its protocol is one UDP
|
||||
/// datagram per command; escape sequences start with a 0x1B byte.
|
||||
public sealed class CwDaemonSender : MessageSender
|
||||
{
|
||||
private readonly UdpClient socket = new();
|
||||
private readonly IPEndPoint daemon;
|
||||
|
||||
public CwDaemonSender(string host = "127.0.0.1", int port = 6789)
|
||||
{
|
||||
daemon = new IPEndPoint(IPAddress.Parse(host), port);
|
||||
IsReady = true;
|
||||
}
|
||||
|
||||
public bool IsReady { get; private set; }
|
||||
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
||||
WriteAsync(Encoding.ASCII.GetBytes(text.ToUpperInvariant()), cancellation);
|
||||
|
||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||
WriteAsync([0x1B, (byte)'4'], cancellation);
|
||||
|
||||
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
|
||||
WriteAsync(Escape('2', wordsPerMinute.ToString()), cancellation);
|
||||
|
||||
public void Dispose() => socket.Dispose();
|
||||
|
||||
private static byte[] Escape(char command, string argument) =>
|
||||
[0x1B, (byte)command, .. Encoding.ASCII.GetBytes(argument)];
|
||||
|
||||
private async Task WriteAsync(byte[] message, CancellationToken cancellation)
|
||||
{
|
||||
try
|
||||
{
|
||||
await socket.SendAsync(message, daemon, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
IsReady = false;
|
||||
throw new InvalidOperationException($"could not reach cwdaemon at {daemon}: {e.Message}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/Nonemm.Keying/MessageSender.cs
Normal file
17
src/Nonemm.Keying/MessageSender.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Nonemm.Keying;
|
||||
|
||||
/// Something that sends a message on the air: a CW keyer, or a voice keyer
|
||||
/// playing a recording.
|
||||
public interface MessageSender : IDisposable
|
||||
{
|
||||
bool IsReady { get; }
|
||||
|
||||
/// Sends the text. Whatever is already going out is finished first unless
|
||||
/// `Abort` is called.
|
||||
Task SendAsync(string text, CancellationToken cancellation = default);
|
||||
|
||||
/// Stops sending straight away, which is what Escape does mid-message.
|
||||
Task AbortAsync(CancellationToken cancellation = default);
|
||||
|
||||
Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default);
|
||||
}
|
||||
@@ -6,4 +6,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.IO.Ports" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
72
src/Nonemm.Keying/WinkeyerSender.cs
Normal file
72
src/Nonemm.Keying/WinkeyerSender.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.IO.Ports;
|
||||
using System.Text;
|
||||
|
||||
namespace Nonemm.Keying;
|
||||
|
||||
/// Sends CW through a WinKeyer on a serial port. The keyer is opened in host
|
||||
/// mode, which is the state where it takes text rather than following a paddle
|
||||
/// alone.
|
||||
public sealed class WinkeyerSender : MessageSender
|
||||
{
|
||||
private const byte AdminCommand = 0x00;
|
||||
private const byte HostOpen = 0x02;
|
||||
private const byte HostClose = 0x03;
|
||||
private const byte SetSpeed = 0x02;
|
||||
private const byte ClearBuffer = 0x0A;
|
||||
|
||||
private readonly SerialPort port;
|
||||
|
||||
public WinkeyerSender(string portName, int baudRate = 1200)
|
||||
{
|
||||
port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.Two);
|
||||
}
|
||||
|
||||
public bool IsReady => port.IsOpen;
|
||||
|
||||
/// Opens the port and puts the keyer in host mode. The keyer answers with
|
||||
/// its firmware version, which is read and thrown away.
|
||||
public void Open()
|
||||
{
|
||||
port.Open();
|
||||
port.Write([AdminCommand, HostOpen], 0, 2);
|
||||
Thread.Sleep(100);
|
||||
port.DiscardInBuffer();
|
||||
}
|
||||
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default)
|
||||
{
|
||||
Write(Encoding.ASCII.GetBytes(text.ToUpperInvariant()));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AbortAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
Write([ClearBuffer]);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default)
|
||||
{
|
||||
Write([SetSpeed, (byte)Math.Clamp(wordsPerMinute, 5, 99)]);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (port.IsOpen)
|
||||
{
|
||||
port.Write([AdminCommand, HostClose], 0, 2);
|
||||
port.Close();
|
||||
}
|
||||
port.Dispose();
|
||||
}
|
||||
|
||||
private void Write(byte[] message)
|
||||
{
|
||||
if (!port.IsOpen)
|
||||
{
|
||||
throw new InvalidOperationException($"the keyer on {port.PortName} is not open");
|
||||
}
|
||||
port.Write(message, 0, message.Length);
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,9 @@ public sealed class LoggingSession
|
||||
return qso;
|
||||
}
|
||||
|
||||
private string DefaultReport() => Mode.Category switch
|
||||
/// The report an operator sends without thinking about it: 599 on CW and
|
||||
/// digital modes, 59 on phone.
|
||||
public string DefaultReport() => Mode.Category switch
|
||||
{
|
||||
ModeCategory.Cw => "599",
|
||||
ModeCategory.Phone => "59",
|
||||
|
||||
63
src/Nonemm.Session/MessageExpander.cs
Normal file
63
src/Nonemm.Session/MessageExpander.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// Fills in a function key message. The macro names are N1MM's, so a message
|
||||
/// file written for either program says the same thing.
|
||||
public static class MessageExpander
|
||||
{
|
||||
/// Cut numbers: a contest operator sends T for zero and N for nine because
|
||||
/// they are shorter.
|
||||
private const string CutDigits = "T12345678N";
|
||||
|
||||
public static string Expand(string template, LoggingSession session)
|
||||
{
|
||||
StringBuilder text = new();
|
||||
int at = 0;
|
||||
while (at < template.Length)
|
||||
{
|
||||
char c = template[at];
|
||||
if (c == '{')
|
||||
{
|
||||
int close = template.IndexOf('}', at + 1);
|
||||
if (close < 0)
|
||||
{
|
||||
text.Append(template[at..]);
|
||||
break;
|
||||
}
|
||||
text.Append(Macro(template[(at + 1)..close], session));
|
||||
at = close + 1;
|
||||
continue;
|
||||
}
|
||||
if (c == '#')
|
||||
{
|
||||
text.Append(session.SentNumber);
|
||||
at++;
|
||||
continue;
|
||||
}
|
||||
text.Append(c);
|
||||
at++;
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
private static string Macro(string name, LoggingSession session) => name.ToUpperInvariant() switch
|
||||
{
|
||||
"MYCALL" => session.Me.Callsign,
|
||||
"CALL" => session.Entry.Call.Trim(),
|
||||
"LOGGEDCALL" => session.Log.Qsos.Count > 0 ? session.Log.Qsos[^1].Call.Text : "",
|
||||
"EXCH" => session.Instance.SentExchange,
|
||||
"SENTRST" => session.DefaultReport(),
|
||||
"SENTRSTCUT" => Cut(session.DefaultReport()),
|
||||
"SENTNR" => session.SentNumber.ToString(),
|
||||
"SENTNRCUT" => Cut(session.SentNumber.ToString()),
|
||||
"NAME" => session.Me.Name,
|
||||
"GRIDSQUARE" => session.Me.GridSquare,
|
||||
"MYZONE" => session.Me.CqZone.ToString(),
|
||||
"OTHERMHZ" => (session.Frequency.Megahertz).ToString("0.###"),
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string Cut(string digits) =>
|
||||
new(digits.Select(d => char.IsAsciiDigit(d) ? CutDigits[d - '0'] : d).ToArray());
|
||||
}
|
||||
Reference in New Issue
Block a user