diff --git a/Nonemm.slnx b/Nonemm.slnx
index 535c3a5..9b59dd6 100644
--- a/Nonemm.slnx
+++ b/Nonemm.slnx
@@ -18,5 +18,7 @@
+
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5f4cbf3
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/docs/n1mm-interop.md b/docs/n1mm-interop.md
new file mode 100644
index 0000000..7b67fe8
--- /dev/null
+++ b/docs/n1mm-interop.md
@@ -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 `` 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.
diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs
index 0e853b9..a1b9b7d 100644
--- a/src/Nonemm.App/AppSession.cs
+++ b/src/Nonemm.App/AppSession.cs
@@ -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();
}
diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs
index d0bf454..bc70c63 100644
--- a/src/Nonemm.App/Configuration/Settings.cs
+++ b/src/Nonemm.App/Configuration/Settings.cs
@@ -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 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 CwMessages { get; init; } = [];
+
+ public IReadOnlyList 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(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;
diff --git a/src/Nonemm.App/Dialogs/ClusterDialog.axaml b/src/Nonemm.App/Dialogs/ClusterDialog.axaml
index c903cfb..5777754 100644
--- a/src/Nonemm.App/Dialogs/ClusterDialog.axaml
+++ b/src/Nonemm.App/Dialogs/ClusterDialog.axaml
@@ -6,7 +6,7 @@
-
+
diff --git a/src/Nonemm.App/Dialogs/KeyerDialog.axaml b/src/Nonemm.App/Dialogs/KeyerDialog.axaml
new file mode 100644
index 0000000..370b5dc
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/KeyerDialog.axaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Dialogs/KeyerDialog.axaml.cs b/src/Nonemm.App/Dialogs/KeyerDialog.axaml.cs
new file mode 100644
index 0000000..b4fc169
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/KeyerDialog.axaml.cs
@@ -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 messageBoxes = [];
+ private List cwMessages;
+ private List 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 shown = CwButton.IsChecked == true ? cwMessages : phoneMessages;
+ for (int at = 0; at < messageBoxes.Count; at++)
+ {
+ messageBoxes[at].Text = shown[at];
+ }
+ }
+
+ private void KeepMessages()
+ {
+ List 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);
+}
diff --git a/src/Nonemm.App/Dialogs/NetworkDialog.axaml b/src/Nonemm.App/Dialogs/NetworkDialog.axaml
index 6899385..d309403 100644
--- a/src/Nonemm.App/Dialogs/NetworkDialog.axaml
+++ b/src/Nonemm.App/Dialogs/NetworkDialog.axaml
@@ -13,7 +13,7 @@
-
+
diff --git a/src/Nonemm.App/Messages.cs b/src/Nonemm.App/Messages.cs
index 2791456..e3bd54b 100644
--- a/src/Nonemm.App/Messages.cs
+++ b/src/Nonemm.App/Messages.cs
@@ -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 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 DefaultPhone =
+ [
+ "CQ CONTEST {MYCALL}",
+ "{EXCH}",
+ "THANK YOU {MYCALL}",
+ "{MYCALL}",
+ "{CALL}",
+ "{EXCH} {EXCH}",
+ "PLEASE REPEAT",
+ "AGAIN",
+ "NUMBER PLEASE",
+ "YOUR CALL PLEASE",
+ "",
+ "",
+ ];
+
+ public static IReadOnlyList For(
+ ModeCategory mode,
+ IReadOnlyList cw,
+ IReadOnlyList phone)
+ {
+ IReadOnlyList stored = mode == ModeCategory.Phone ? phone : cw;
+ IReadOnlyList fallback = mode == ModeCategory.Phone ? DefaultPhone : DefaultCw;
+ return stored.Count == Keys.Count ? stored : fallback;
+ }
}
diff --git a/src/Nonemm.App/Nonemm.App.csproj b/src/Nonemm.App/Nonemm.App.csproj
index cce3bdb..2d51ea1 100644
--- a/src/Nonemm.App/Nonemm.App.csproj
+++ b/src/Nonemm.App/Nonemm.App.csproj
@@ -27,5 +27,6 @@
+
diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs
index e547a09..9a3d7ad 100644
--- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs
+++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs
@@ -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(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),
diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml
index ec9f071..825e072 100644
--- a/src/Nonemm.App/Windows/EntryWindow.axaml
+++ b/src/Nonemm.App/Windows/EntryWindow.axaml
@@ -51,6 +51,7 @@
+
diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs
index 1d8867e..74e7c62 100644
--- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs
+++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs
@@ -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");
+ }
}
diff --git a/src/Nonemm.Keying/CwDaemonSender.cs b/src/Nonemm.Keying/CwDaemonSender.cs
new file mode 100644
index 0000000..85b7dc5
--- /dev/null
+++ b/src/Nonemm.Keying/CwDaemonSender.cs
@@ -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);
+ }
+ }
+}
diff --git a/src/Nonemm.Keying/MessageSender.cs b/src/Nonemm.Keying/MessageSender.cs
new file mode 100644
index 0000000..8ea3648
--- /dev/null
+++ b/src/Nonemm.Keying/MessageSender.cs
@@ -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);
+}
diff --git a/src/Nonemm.Keying/Nonemm.Keying.csproj b/src/Nonemm.Keying/Nonemm.Keying.csproj
index b760144..6d13687 100644
--- a/src/Nonemm.Keying/Nonemm.Keying.csproj
+++ b/src/Nonemm.Keying/Nonemm.Keying.csproj
@@ -6,4 +6,8 @@
enable
+
+
+
+
diff --git a/src/Nonemm.Keying/WinkeyerSender.cs b/src/Nonemm.Keying/WinkeyerSender.cs
new file mode 100644
index 0000000..699a384
--- /dev/null
+++ b/src/Nonemm.Keying/WinkeyerSender.cs
@@ -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);
+ }
+}
diff --git a/src/Nonemm.Session/LoggingSession.cs b/src/Nonemm.Session/LoggingSession.cs
index ebfec31..07f6ee4 100644
--- a/src/Nonemm.Session/LoggingSession.cs
+++ b/src/Nonemm.Session/LoggingSession.cs
@@ -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",
diff --git a/src/Nonemm.Session/MessageExpander.cs b/src/Nonemm.Session/MessageExpander.cs
new file mode 100644
index 0000000..a1ca380
--- /dev/null
+++ b/src/Nonemm.Session/MessageExpander.cs
@@ -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());
+}
diff --git a/tests/Nonemm.Keying.Tests/CwDaemonSenderTests.cs b/tests/Nonemm.Keying.Tests/CwDaemonSenderTests.cs
new file mode 100644
index 0000000..0defbaf
--- /dev/null
+++ b/tests/Nonemm.Keying.Tests/CwDaemonSenderTests.cs
@@ -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 NextDatagram(Func send)
+ {
+ Task 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);
+ }
+}
diff --git a/tests/Nonemm.Keying.Tests/Nonemm.Keying.Tests.csproj b/tests/Nonemm.Keying.Tests/Nonemm.Keying.Tests.csproj
new file mode 100644
index 0000000..e1167b6
--- /dev/null
+++ b/tests/Nonemm.Keying.Tests/Nonemm.Keying.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Nonemm.Network.Tests/ContactMessageTests.cs b/tests/Nonemm.Network.Tests/ContactMessageTests.cs
new file mode 100644
index 0000000..c8e602d
--- /dev/null
+++ b/tests/Nonemm.Network.Tests/ContactMessageTests.cs
@@ -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("1402550", ContactMessage.Write(Contact(), "DL1ABC", "PC1"));
+
+ [Theory]
+ [InlineData("N1MM")]
+ [InlineData("not xml at all")]
+ [InlineData("")]
+ public void AnythingThatIsNotAContactIsPassedOver(string message) =>
+ Assert.Null(ContactMessage.Read(message));
+}
diff --git a/tests/Nonemm.Network.Tests/Nonemm.Network.Tests.csproj b/tests/Nonemm.Network.Tests/Nonemm.Network.Tests.csproj
new file mode 100644
index 0000000..7683804
--- /dev/null
+++ b/tests/Nonemm.Network.Tests/Nonemm.Network.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Nonemm.Session.Tests/MessageExpanderTests.cs b/tests/Nonemm.Session.Tests/MessageExpanderTests.cs
new file mode 100644
index 0000000..563b980
--- /dev/null
+++ b/tests/Nonemm.Session.Tests/MessageExpanderTests.cs
@@ -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()));
+}