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:
Erik
2026-08-27 11:23:30 +00:00
parent d96cbe146b
commit f5aa77ece3
25 changed files with 1002 additions and 26 deletions

View File

@@ -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();
}

View File

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

View File

@@ -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>

View 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>

View 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);
}

View File

@@ -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" />

View File

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

View File

@@ -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>

View File

@@ -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),

View File

@@ -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" />

View File

@@ -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");
}
}

View 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);
}
}
}

View 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);
}

View File

@@ -6,4 +6,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IO.Ports" Version="10.0.11" />
</ItemGroup>
</Project>

View 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);
}
}

View File

@@ -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",

View 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());
}