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