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