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

@@ -18,5 +18,7 @@
<Project Path="tests/Nonemm.Storage.Tests/Nonemm.Storage.Tests.csproj" />
<Project Path="tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj" />
<Project Path="tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj" />
<Project Path="tests/Nonemm.Keying.Tests/Nonemm.Keying.Tests.csproj" />
<Project Path="tests/Nonemm.Network.Tests/Nonemm.Network.Tests.csproj" />
</Folder>
</Solution>

141
README.md Normal file
View File

@@ -0,0 +1,141 @@
# Nonemm
A contest logger for amateur radio: a from-scratch reimplementation of
[N1MM Logger+](https://n1mmwp.hamdocs.com/) in C#, running on Linux and Windows.
The contest rules, log formats and database schema are written from their
published definitions. What Nonemm keeps is **interoperability**: the log
database is N1MM's `.s3db` in N1MM's schema, user-defined contests are N1MM's
`.udc` files, and a log either program writes opens in the other.
## Running it
The SDK lives at `~/.dotnet` on this machine, so a shell that has not been set
up needs:
```sh
export DOTNET_ROOT=$HOME/.dotnet PATH="$HOME/.dotnet:$PATH"
```
Then:
```sh
dotnet run --project src/Nonemm.App # start the logger
dotnet test Nonemm.slnx # run every test
```
`./build.sh` does the same with the environment already set: `./build.sh test
Nonemm.slnx`.
To work a contest: **Config → Station** for your callsign and zones, then
**File → New Database** and **File → New Contest**. In the entry window, type a
callsign and press **space** to move to the exchange, then **Enter** to log.
Type a frequency into the callsign box and press Enter to change band. **View**
opens the log, check, bandmap, score and packet windows.
Everything that judges a station — the bar under the callsign box, a row in the
check window, a spot on the bandmap, a line in the log — is coloured by the same
scorer: red for a dupe, green for a new multiplier, blue for points.
## What it does
| | |
|---|---|
| Contests | CQ WW, CQ WPX, ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, general logging, and user-defined `.udc` contests |
| Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out |
| While typing | dupe check, multiplier check, points, country and zone from the country file |
| Windows | entry, log, check, bandmap, score summary, packet |
| Radio | hamlib `rigctld`, reconnecting on its own |
| Cluster | DX cluster over telnet, spots feeding the bandmap |
| Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message |
| Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros |
### The country file and the callsign database
Neither is bundled. **Config → Download Country File** and **Download Check
Partial File** fetch them from where they are published, which is where N1MM
fetches them from too —
[`country-files.com/cty/wl_cty.dat`](https://www.country-files.com/cty/wl_cty.dat)
and
[`supercheckpartial.com/MASTER.SCP`](https://www.supercheckpartial.com/MASTER.SCP).
The country file is `wl_cty.dat` rather than plain `cty.dat`: same format, with
the WAE entities listed separately, which is what CQ WW counts.
A download that fails changes nothing. The file is fetched, checked that it
parses as what it claims to be, and only then put in place, so a site that
answers with an apology page instead of a country file cannot cost an operator
their multipliers mid-contest.
Both files can also be dropped into `SupportFiles` under the configuration
directory (`~/.config/nonemm` on Linux, `Documents\Nonemm` on Windows).
User-defined contests go in `UserDefinedContests` under the same directory.
Without a country file the program still runs; country- and continent-scored
contests lose accuracy.
### Radio control
The logger reads and tunes the radio through
[hamlib](https://hamlib.github.io/)'s `rigctld`, started separately for whichever
radio is on the desk:
```sh
rigctld -m 2028 -r /dev/ttyUSB0 # -m is the hamlib model number; rigctl -l lists them
```
Then **Config → Radio**. With no radio connected nothing changes: frequency and
mode stay where they were last typed.
### CW
**Config → Keyer and messages** picks `cwdaemon` (a UDP port, usually 6789) or a
WinKeyer (a serial port), sets the speed, and edits the twelve function key
messages for CW and for phone. The macros are N1MM's: `{MYCALL}`, `{CALL}`,
`{EXCH}`, `{SENTRST}`, `{SENTNR}`, `#` for the serial number, and `{SENTRSTCUT}`
for cut numbers. Escape stops sending.
### Networked stations
**Config → Network** names this station and lists the others. Each contact is
sent to them as it is logged, in N1MM's `contactinfo` message, so an N1MM
station on the same network sees them too. With no addresses listed the contacts
are broadcast. A contact that arrives is scored again here from the rules rather
than trusted.
## Layout
| Project | What it holds |
|---|---|
| `Nonemm.Core` | Frequencies, bands, modes, callsigns, grid squares, the country file and the callsign database |
| `Nonemm.Contests` | Contest rules, the scoring engine, `.udc` files |
| `Nonemm.Formats` | Cabrillo out, ADIF in and out |
| `Nonemm.Storage` | The N1MM-compatible `.s3db` |
| `Nonemm.Rig` | Radio control over `rigctld` |
| `Nonemm.Spotting` | Spots, the bandmap, the DX cluster client |
| `Nonemm.Network` | Contacts shared between the stations of a multi-operator entry |
| `Nonemm.Keying` | CW through `cwdaemon` or a WinKeyer |
| `Nonemm.Session` | What the operator is typing and what the log says about it — no UI toolkit |
| `Nonemm.App` | The Avalonia windows |
The split at `Nonemm.Session` is the important one: it references no UI
framework, so what space does, when a dupe fires and what a contact scores are
covered by plain unit tests.
## Where it stands
Working: logging a contest end to end, live dupe and multiplier checking, eight
built-in contests plus user-defined ones, Cabrillo and ADIF export, ADIF import,
the log, check, bandmap, score and packet windows, radio control, DX cluster
spots, contacts shared between networked stations, and CW keying.
Checked against N1MM 1.0.11031: a log this program wrote opens in N1MM, which
reads the contest, its categories and the contacts. See
[`docs/n1mm-interop.md`](docs/n1mm-interop.md) for what that took.
Not yet: voice keying, QTC handling for WAE, call history files, digital modes
beyond logging them, and the check window's Call History and Exchange columns,
which are left out rather than shown empty.
The radio, cluster, network and keyer clients are tested against fakes that
speak the documented protocols. None has yet been run against a real radio, a
live cluster node or a keyer.

54
docs/n1mm-interop.md Normal file
View File

@@ -0,0 +1,54 @@
# Opening a Nonemm log in N1MM
Checked on 2026-08-27 against N1MM Logger+ 1.0.11031 by writing a log here,
copying it to a Windows machine and opening it there. N1MM read the contest, its
categories and the contacts.
Three things had to be right, and none of them is obvious from the schema.
## The `Contest` table needs a row for the contest
`ContestInstance` says which contest a log is; the `Contest` table says what that
contest *is*. N1MM reads it in `Contest.FromRow` when it opens a log and throws
`InvalidOperationException: No current row` if the row is missing, which reaches
the operator as "A runtime error occurred".
So the definition row is written whenever a contest is opened, not only when it
is created — a log made before this was understood gets its row the next time it
is opened. `ContestDefinitions.For` builds the row from the contest's own rules:
the display and Cabrillo names, the mode, the dupe type and the multiplier
names.
## The overlay category cannot be empty
An empty `ContestInstance.OverlayCategory` is answered with "Invalid Overlay
Category:" and the log will not open. An entry with no overlay says `N/A`.
N1MM's list is not the Cabrillo specification's list. N1MM offers:
N/A, ROOKIE, BAND-LIMITED, TB-WIRES, OVER-50, HQ, NOVICE-TECH, EXPERT
so that is what the contest dialog offers.
## The sent exchange omits the report
N1MM's contest dialog says "Omit RST: CQWW: 05". `SentExchange` for CQ WW is
`14`, not `599 14`; for a serial number contest it is `001`. The report is fixed
for the whole contest and the entry window fills it in per contact.
## Getting the schema in the first place
N1MM ships no template database. `ham.s3db` is built at run time by applying SQL
migration files that N1MM writes out from string resources inside
`N1MMLogger.net.exe`. To read them: decompile the executable, parse
`N1MMLogger.Net.Resources.resx`, and take the untyped `<data>` entries named
`DXLogDDL_0001_initial_schema_and_data` through `DXLogDDL_0004_updates`. The QSO
table is `DXLOG` and the current `PRAGMA user_version` is 4.
`src/Nonemm.Storage/Schema.sql` is that schema, flattened to the state version 4
leaves behind.
## One thing to avoid
Do not replace the database file under a running N1MM. It does not notice and
throws on the next read.

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

View File

@@ -0,0 +1,52 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace Nonemm.Keying.Tests;
/// The sender is tested against a socket that speaks cwdaemon's side of the
/// protocol, so what goes on the wire is what is asserted.
public class CwDaemonSenderTests : IDisposable
{
private readonly UdpClient daemon = new(new IPEndPoint(IPAddress.Loopback, 0));
private int Port => ((IPEndPoint)daemon.Client.LocalEndPoint!).Port;
public void Dispose()
{
daemon.Dispose();
GC.SuppressFinalize(this);
}
private async Task<byte[]> NextDatagram(Func<Task> send)
{
Task<UdpReceiveResult> receiving = daemon.ReceiveAsync();
await send();
UdpReceiveResult received = await receiving.WaitAsync(TimeSpan.FromSeconds(5));
return received.Buffer;
}
[Fact]
public async Task TextGoesOutInUpperCase()
{
using CwDaemonSender sender = new("127.0.0.1", Port);
byte[] sent = await NextDatagram(() => sender.SendAsync("cq test de dl1abc"));
Assert.Equal("CQ TEST DE DL1ABC", Encoding.ASCII.GetString(sent));
}
[Fact]
public async Task AbortIsTheEscapeFourCommand()
{
using CwDaemonSender sender = new("127.0.0.1", Port);
byte[] sent = await NextDatagram(() => sender.AbortAsync());
Assert.Equal([0x1B, (byte)'4'], sent);
}
[Fact]
public async Task SpeedIsTheEscapeTwoCommand()
{
using CwDaemonSender sender = new("127.0.0.1", Port);
byte[] sent = await NextDatagram(() => sender.SetSpeedAsync(32));
Assert.Equal([0x1B, (byte)'2', (byte)'3', (byte)'2'], sent);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Nonemm.Keying\Nonemm.Keying.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,59 @@
using Nonemm.Core;
namespace Nonemm.Network.Tests;
public class ContactMessageTests
{
private static Qso Contact() => new()
{
Id = "0123456789abcdef0123456789abcdef",
TimestampUtc = new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc),
Call = Callsign.Parse("JA1XYZ"),
Frequency = Frequency.FromKilohertz(14_025.5),
Mode = Modes.Cw,
ContestName = "CQWW",
ContestNumber = 3,
SentReport = "599",
ReceivedReport = "579",
Zone = 25,
Points = 3,
IsMultiplier1 = true,
CountryPrefix = "JA",
Continent = "AS",
WpxPrefix = "JA1",
IsRunQso = true,
};
[Fact]
public void ContactSurvivesTheRoundTrip()
{
Qso? read = ContactMessage.Read(ContactMessage.Write(Contact(), "DL1ABC", "PC1"));
Assert.NotNull(read);
Assert.Equal("0123456789abcdef0123456789abcdef", read.Id);
Assert.Equal("JA1XYZ", read.Call.Text);
Assert.Equal(14_025_500, read.Frequency.Hertz);
Assert.Equal(new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc), read.TimestampUtc);
Assert.Equal(25, read.Zone);
Assert.Equal("JA", read.CountryPrefix);
Assert.True(read.IsRunQso);
Assert.Equal("PC1", read.StationName);
}
/// A contact that arrived over the network was made somewhere else, whatever
/// the sender put in the field.
[Fact]
public void AContactFromTheNetworkIsNotOriginal() =>
Assert.False(ContactMessage.Read(ContactMessage.Write(Contact(), "DL1ABC", "PC1"))!.IsOriginal);
[Fact]
public void FrequenciesAreSentInUnitsOfTenHertz() =>
Assert.Contains("<rxfreq>1402550</rxfreq>", ContactMessage.Write(Contact(), "DL1ABC", "PC1"));
[Theory]
[InlineData("<radioinfo><app>N1MM</app></radioinfo>")]
[InlineData("not xml at all")]
[InlineData("<contactinfo><call></call></contactinfo>")]
public void AnythingThatIsNotAContactIsPassedOver(string message) =>
Assert.Null(ContactMessage.Read(message));
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Nonemm.Network\Nonemm.Network.csproj" />
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,54 @@
using Nonemm.Contests;
using Nonemm.Contests.Rules;
using Nonemm.Core;
using Nonemm.Storage;
namespace Nonemm.Session.Tests;
public class MessageExpanderTests
{
private static LoggingSession Session()
{
FakeLogStore store = new();
ContestInstance instance = store.AddContest(new ContestInstance
{
ContestNumber = 0,
ContestName = "CQWW",
SentExchange = "14",
});
return new LoggingSession(
store,
new CqWorldWide(ModeCategory.Cw),
instance,
new StationInfo { Callsign = "DL1ABC", CqZone = 14, Name = "Erik" },
null);
}
[Fact]
public void MyCallAndTheirCallAreFilledIn()
{
LoggingSession session = Session();
session.Entry.Call = "JA1XYZ";
Assert.Equal("JA1XYZ DE DL1ABC", MessageExpander.Expand("{CALL} DE {MYCALL}", session));
}
[Fact]
public void TheSentExchangeComesFromTheContestSetup() =>
Assert.Equal("599 14", MessageExpander.Expand("{SENTRST} {EXCH}", Session()));
[Fact]
public void HashIsTheSerialNumber() =>
Assert.Equal("NR 1", MessageExpander.Expand("NR #", Session()));
[Fact]
public void CutNumbersReplaceZeroAndNine() =>
Assert.Equal("5NN", MessageExpander.Expand("{SENTRSTCUT}", Session()));
[Fact]
public void AMacroThatIsNotKnownDisappears() =>
Assert.Equal("TU ", MessageExpander.Expand("TU {NOTAMACRO}", Session()));
[Fact]
public void AnUnclosedMacroIsLeftAsTyped() =>
Assert.Equal("TU {MYCALL", MessageExpander.Expand("TU {MYCALL", Session()));
}