diff --git a/Nonemm.slnx b/Nonemm.slnx
index 2dbf99f..535c3a5 100644
--- a/Nonemm.slnx
+++ b/Nonemm.slnx
@@ -9,6 +9,7 @@
+
diff --git a/src/Nonemm.App/App.axaml b/src/Nonemm.App/App.axaml
new file mode 100644
index 0000000..76dc11b
--- /dev/null
+++ b/src/Nonemm.App/App.axaml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Nonemm.App/App.axaml.cs b/src/Nonemm.App/App.axaml.cs
new file mode 100644
index 0000000..45db542
--- /dev/null
+++ b/src/Nonemm.App/App.axaml.cs
@@ -0,0 +1,25 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Nonemm.App.Configuration;
+using Nonemm.App.Windows;
+
+namespace Nonemm.App;
+
+public partial class App : Application
+{
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ UserPaths paths = UserPaths.Default();
+ paths.CreateFolders();
+ AppSession session = new(paths, Settings.Load(paths.SettingsFile));
+ desktop.MainWindow = new EntryWindow(session);
+ desktop.ShutdownRequested += (_, _) => session.Dispose();
+ }
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs
new file mode 100644
index 0000000..cf74bab
--- /dev/null
+++ b/src/Nonemm.App/AppSession.cs
@@ -0,0 +1,237 @@
+using Nonemm.App.Configuration;
+using Nonemm.Contests;
+using Nonemm.Core;
+using Nonemm.Core.Calls;
+using Nonemm.Core.Country;
+using Nonemm.Network;
+using Nonemm.Rig;
+using Nonemm.Session;
+using Nonemm.Spotting;
+using Nonemm.Storage;
+
+namespace Nonemm.App;
+
+/// Everything the running program owns: the log store, the contest in progress,
+/// the bandmap, the radio and the cluster. The windows read from this and one
+/// `Changed` event tells them all to redraw.
+public sealed class AppSession : IDisposable
+{
+ private LogStore? store;
+ private RigctldRadio? radio;
+ private ClusterClient? cluster;
+ private StationNetwork? network;
+
+ public AppSession(UserPaths paths, Settings settings)
+ {
+ Paths = paths;
+ Settings = settings;
+ paths.CreateFolders();
+ Countries = LoadCountryFile(paths.CountryFile);
+ Calls = LoadCallDatabase(paths.CallDatabaseFile);
+ Registry = ContestRegistry.FromFolder(paths.UserDefinedContests, out IReadOnlyList problems);
+ UserDefinedContestProblems = problems;
+ }
+
+ public UserPaths Paths { get; }
+
+ public Settings Settings { get; private set; }
+
+ public ContestRegistry Registry { get; private set; }
+
+ public IReadOnlyList UserDefinedContestProblems { get; }
+
+ public CountryFile? Countries { get; private set; }
+
+ public CallDatabase Calls { get; private set; }
+
+ public Bandmap Bandmap { get; } = new();
+
+ public LoggingSession? Logging { get; private set; }
+
+ public CheckWindowSources? Check { get; private set; }
+
+ public Radio? Radio => radio;
+
+ public ClusterClient? Cluster => cluster;
+
+ public StationNetwork? Network => network;
+
+ public event EventHandler? Changed;
+
+ public event EventHandler? ContestChanged;
+
+ public void Save(Settings settings)
+ {
+ Settings = settings;
+ settings.Save(Paths.SettingsFile);
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void OpenDatabase(string path)
+ {
+ bool sameFile = string.Equals(Settings.DatabasePath, path, StringComparison.Ordinal);
+ store?.Dispose();
+ store = SqliteLogStore.Open(path);
+ Logging = null;
+ Check = null;
+ Save(Settings with
+ {
+ DatabasePath = path,
+ ContestNumber = sameFile ? Settings.ContestNumber : 0,
+ });
+ ContestChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ public LogStore Store =>
+ store ?? throw new InvalidOperationException("no log database is open");
+
+ public ContestInstance StartContest(ContestInstance instance)
+ {
+ ContestInstance stored = Store.AddContest(instance);
+ OpenContest(stored.ContestNumber);
+ return stored;
+ }
+
+ public void OpenContest(int contestNumber)
+ {
+ ContestInstance instance = Store.Contest(contestNumber)
+ ?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
+ Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
+ Logging = new LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries);
+ Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
+ Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
+ qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
+ Logging.Logged += (_, qso) => _ = network?.SendAsync(qso, Settings.Station.Callsign);
+ Check = new CheckWindowSources(Logging, Calls, Bandmap);
+ Save(Settings with { ContestNumber = contestNumber });
+ ContestChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ /// Starts, restarts or stops the link to the other stations of a
+ /// multi-operator entry, following what the settings now say.
+ public void ApplyNetworkSettings()
+ {
+ network?.Dispose();
+ network = null;
+ if (!Settings.NetworkEnabled)
+ {
+ Changed?.Invoke(this, EventArgs.Empty);
+ return;
+ }
+ network = new StationNetwork(
+ Settings.NetworkPort,
+ Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName,
+ Settings.NetworkPeers);
+ network.ContactArrived += (_, qso) => TakeFromNetwork(qso);
+ network.Start();
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ /// A contact another station logged. It goes into the same log under the
+ /// contest that is open, and is scored here rather than trusting the
+ /// points the sender put in the message.
+ private void TakeFromNetwork(Qso qso)
+ {
+ if (Logging is null || store is null)
+ {
+ return;
+ }
+ if (Logging.Log.Qsos.Any(q => q.Id == qso.Id))
+ {
+ return;
+ }
+ Qso mine = qso with { ContestNumber = Logging.Instance.ContestNumber, IsOriginal = false };
+ store.Add(mine);
+ OpenContest(Logging.Instance.ContestNumber);
+ }
+
+ public void ConnectRadio()
+ {
+ radio?.Dispose();
+ radio = new RigctldRadio(Settings.RigctldHost, Settings.RigctldPort);
+ radio.Moved += (_, state) => Logging?.Tune(state.Frequency, state.Mode);
+ radio.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
+ radio.Start();
+ }
+
+ public void DisconnectRadio()
+ {
+ radio?.Dispose();
+ radio = null;
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void ConnectCluster()
+ {
+ cluster?.Dispose();
+ cluster = new ClusterClient(
+ Settings.ClusterHost,
+ Settings.ClusterPort,
+ Settings.Station.Callsign,
+ Settings.ClusterCommands);
+ cluster.SpotArrived += (_, spot) => Bandmap.Add(spot);
+ cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
+ cluster.Start();
+ }
+
+ public void DisconnectCluster()
+ {
+ cluster?.Dispose();
+ cluster = null;
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void ReloadSupportFiles()
+ {
+ Countries = LoadCountryFile(Paths.CountryFile);
+ Calls = LoadCallDatabase(Paths.CallDatabaseFile);
+ Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _);
+ if (Logging is not null)
+ {
+ OpenContest(Logging.Instance.ContestNumber);
+ }
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void Dispose()
+ {
+ radio?.Dispose();
+ cluster?.Dispose();
+ network?.Dispose();
+ store?.Dispose();
+ }
+
+ private static ModeCategory ModeCategoryOf(ContestInstance instance) =>
+ instance.ModeCategory.ToUpperInvariant() switch
+ {
+ "SSB" or "PH" or "PHONE" => ModeCategory.Phone,
+ "RTTY" or "DIGI" or "DIGITAL" => ModeCategory.Digital,
+ _ => ModeCategory.Cw,
+ };
+
+ /// Without a country file the program still runs; country and continent
+ /// scoring just loses accuracy.
+ private static CountryFile? LoadCountryFile(string path)
+ {
+ try
+ {
+ return File.Exists(path) ? CountryFile.Parse(File.ReadAllText(path)) : null;
+ }
+ catch (Exception e) when (e is FormatException or IOException)
+ {
+ return null;
+ }
+ }
+
+ private static CallDatabase LoadCallDatabase(string path)
+ {
+ try
+ {
+ return File.Exists(path) ? CallDatabase.Parse(File.ReadAllText(path)) : CallDatabase.Empty;
+ }
+ catch (Exception e) when (e is FormatException or IOException)
+ {
+ return CallDatabase.Empty;
+ }
+ }
+}
diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs
new file mode 100644
index 0000000..d0bf454
--- /dev/null
+++ b/src/Nonemm.App/Configuration/Settings.cs
@@ -0,0 +1,117 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Nonemm.Core;
+
+namespace Nonemm.App.Configuration;
+
+/// What the program remembers between runs.
+public sealed record Settings
+{
+ public string DatabasePath { get; init; } = "";
+
+ public int ContestNumber { get; init; }
+
+ public StoredStation Station { get; init; } = new();
+
+ public string ClusterHost { get; init; } = "";
+
+ public int ClusterPort { get; init; } = 7373;
+
+ public IReadOnlyList ClusterCommands { get; init; } = [];
+
+ public string RigctldHost { get; init; } = "127.0.0.1";
+
+ public int RigctldPort { get; init; } = 4532;
+
+ public bool RadioEnabled { get; init; }
+
+ public bool ClusterEnabled { get; init; }
+
+ public string NetworkStationName { get; init; } = "";
+
+ public int NetworkPort { get; init; } = 12060;
+
+ public bool NetworkEnabled { get; init; }
+
+ public IReadOnlyList NetworkPeers { get; init; } = [];
+
+ public static Settings Load(string path)
+ {
+ if (!File.Exists(path))
+ {
+ return new Settings();
+ }
+ try
+ {
+ return JsonSerializer.Deserialize(File.ReadAllText(path), SettingsJson.Default.Settings)
+ ?? new Settings();
+ }
+ catch (JsonException)
+ {
+ // a settings file that will not parse is replaced rather than
+ // stopping the program before a contest
+ return new Settings();
+ }
+ }
+
+ public void Save(string path) =>
+ File.WriteAllText(path, JsonSerializer.Serialize(this, SettingsJson.Default.Settings));
+}
+
+/// The operator's station as it is stored, kept separate from `StationInfo` so
+/// the file format does not follow every change to the domain type.
+public sealed record StoredStation
+{
+ public string Callsign { get; init; } = "";
+
+ public int CqZone { get; init; }
+
+ public int ItuZone { get; init; }
+
+ public string Continent { get; init; } = "";
+
+ public string CountryPrefix { get; init; } = "";
+
+ public string State { get; init; } = "";
+
+ public string Province { get; init; } = "";
+
+ public string ArrlSection { get; init; } = "";
+
+ public string GridSquare { get; init; } = "";
+
+ public string County { get; init; } = "";
+
+ public string Name { get; init; } = "";
+
+ public string Power { get; init; } = "";
+
+ public string Club { get; init; } = "";
+
+ public int Check { get; init; }
+
+ public string Precedence { get; init; } = "A";
+
+ public StationInfo ToStationInfo() => new()
+ {
+ Callsign = Callsign,
+ CqZone = CqZone,
+ ItuZone = ItuZone,
+ Continent = Continent,
+ CountryPrefix = CountryPrefix,
+ State = State,
+ Province = Province,
+ ArrlSection = ArrlSection,
+ GridSquare = GridSquare,
+ County = County,
+ Name = Name,
+ Power = Power,
+ Club = Club,
+ Check = Check,
+ Precedence = Precedence,
+ };
+}
+
+[JsonSerializable(typeof(Settings))]
+[JsonSourceGenerationOptions(WriteIndented = true)]
+internal sealed partial class SettingsJson : JsonSerializerContext;
diff --git a/src/Nonemm.App/Configuration/SupportFileDownloader.cs b/src/Nonemm.App/Configuration/SupportFileDownloader.cs
new file mode 100644
index 0000000..16697ee
--- /dev/null
+++ b/src/Nonemm.App/Configuration/SupportFileDownloader.cs
@@ -0,0 +1,59 @@
+using Nonemm.Core.Calls;
+using Nonemm.Core.Country;
+
+namespace Nonemm.App.Configuration;
+
+/// Fetches the country file and the callsign database from where they are
+/// published, which is where N1MM fetches them from too. The file is checked
+/// that it parses as what it claims to be before it replaces the old one, so a
+/// site that answers with an apology page cannot cost an operator their
+/// multipliers mid-contest.
+public sealed class SupportFileDownloader
+{
+ public const string CountryFileUrl = "https://www.country-files.com/cty/wl_cty.dat";
+ public const string CallDatabaseUrl = "https://www.supercheckpartial.com/MASTER.SCP";
+
+ private readonly HttpClient http;
+
+ public SupportFileDownloader(HttpClient http) => this.http = http;
+
+ public Task DownloadCountryFileAsync(string path, CancellationToken cancellation = default) =>
+ DownloadAsync(CountryFileUrl, path, text => CountryFile.Parse(text).Entities.Count, "entities", cancellation);
+
+ public Task DownloadCallDatabaseAsync(string path, CancellationToken cancellation = default) =>
+ DownloadAsync(CallDatabaseUrl, path, text => CallDatabase.Parse(text).Count, "callsigns", cancellation);
+
+ private async Task DownloadAsync(
+ string url,
+ string path,
+ Func check,
+ string what,
+ CancellationToken cancellation)
+ {
+ string text;
+ try
+ {
+ text = await http.GetStringAsync(url, cancellation).ConfigureAwait(false);
+ }
+ catch (HttpRequestException e)
+ {
+ throw new InvalidOperationException($"could not fetch {url}: {e.Message}", e);
+ }
+
+ int count;
+ try
+ {
+ count = check(text);
+ }
+ catch (FormatException e)
+ {
+ throw new InvalidOperationException(
+ $"{url} did not answer with a usable file, so the old one is untouched: {e.Message}", e);
+ }
+
+ string temporary = path + ".new";
+ await File.WriteAllTextAsync(temporary, text, cancellation).ConfigureAwait(false);
+ File.Move(temporary, path, overwrite: true);
+ return $"{count} {what}";
+ }
+}
diff --git a/src/Nonemm.App/Configuration/UserPaths.cs b/src/Nonemm.App/Configuration/UserPaths.cs
new file mode 100644
index 0000000..a388ade
--- /dev/null
+++ b/src/Nonemm.App/Configuration/UserPaths.cs
@@ -0,0 +1,47 @@
+namespace Nonemm.App.Configuration;
+
+/// Where the operator's files live. Windows keeps them under Documents the way
+/// N1MM does; everywhere else follows the XDG configuration directory.
+public sealed class UserPaths
+{
+ public UserPaths(string root)
+ {
+ Root = root;
+ Databases = Path.Combine(root, "Databases");
+ UserDefinedContests = Path.Combine(root, "UserDefinedContests");
+ SupportFiles = Path.Combine(root, "SupportFiles");
+ }
+
+ public static UserPaths Default() => new(DefaultRoot());
+
+ public string Root { get; }
+
+ public string Databases { get; }
+
+ public string UserDefinedContests { get; }
+
+ public string SupportFiles { get; }
+
+ public string SettingsFile => Path.Combine(Root, "settings.json");
+
+ public string CountryFile => Path.Combine(SupportFiles, "wl_cty.dat");
+
+ public string CallDatabaseFile => Path.Combine(SupportFiles, "MASTER.SCP");
+
+ public void CreateFolders()
+ {
+ Directory.CreateDirectory(Root);
+ Directory.CreateDirectory(Databases);
+ Directory.CreateDirectory(UserDefinedContests);
+ Directory.CreateDirectory(SupportFiles);
+ }
+
+ private static string DefaultRoot() =>
+ OperatingSystem.IsWindows()
+ ? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
+ "Nonemm")
+ : Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "nonemm");
+}
diff --git a/src/Nonemm.App/Dialogs/ClusterDialog.axaml b/src/Nonemm.App/Dialogs/ClusterDialog.axaml
new file mode 100644
index 0000000..c903cfb
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/ClusterDialog.axaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs b/src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs
new file mode 100644
index 0000000..2e154a2
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs
@@ -0,0 +1,37 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Nonemm.App.Configuration;
+
+namespace Nonemm.App.Dialogs;
+
+/// The cluster node to connect to. Filters are the node's business, so whatever
+/// the operator puts in the command box is sent after login and left alone.
+public sealed partial class ClusterDialog : Window
+{
+ private readonly Settings settings;
+
+ public ClusterDialog(Settings settings)
+ {
+ this.settings = settings;
+ InitializeComponent();
+ HostBox.Text = settings.ClusterHost;
+ PortBox.Text = settings.ClusterPort.ToString();
+ CommandsBox.Text = string.Join("\n", settings.ClusterCommands);
+ EnabledBox.IsChecked = settings.ClusterEnabled;
+ }
+
+
+ private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
+ {
+ ClusterHost = (HostBox.Text ?? "").Trim(),
+ ClusterPort = int.TryParse(PortBox.Text, out int port) ? port : 7373,
+ ClusterCommands = (CommandsBox.Text ?? "")
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .Select(l => l.Trim())
+ .Where(l => l.Length > 0)
+ .ToList(),
+ ClusterEnabled = EnabledBox.IsChecked == true,
+ });
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
+}
diff --git a/src/Nonemm.App/Dialogs/ContestPickerDialog.axaml b/src/Nonemm.App/Dialogs/ContestPickerDialog.axaml
new file mode 100644
index 0000000..854c232
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/ContestPickerDialog.axaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Dialogs/ContestPickerDialog.axaml.cs b/src/Nonemm.App/Dialogs/ContestPickerDialog.axaml.cs
new file mode 100644
index 0000000..0dea595
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/ContestPickerDialog.axaml.cs
@@ -0,0 +1,31 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Nonemm.Storage;
+
+namespace Nonemm.App.Dialogs;
+
+/// Picks one of the contests already in the log database.
+public sealed partial class ContestPickerDialog : Window
+{
+ private readonly IReadOnlyList contests;
+
+ public ContestPickerDialog(IReadOnlyList contests)
+ {
+ this.contests = contests;
+ InitializeComponent();
+ ContestList.ItemsSource = contests
+ .Select(c => $"{c.ContestNumber} {c.ContestName} {c.StartDate:yyyy-MM-dd} {c.OperatorCategory}")
+ .ToList();
+ ContestList.SelectedIndex = contests.Count - 1;
+ ContestList.DoubleTapped += (_, _) => OnOpen(this, new RoutedEventArgs());
+ }
+
+
+ private void OnOpen(object? sender, RoutedEventArgs e)
+ {
+ int at = ContestList.SelectedIndex;
+ Close(at >= 0 ? contests[at].ContestNumber : (int?)null);
+ }
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
+}
diff --git a/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml
new file mode 100644
index 0000000..afd60b9
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs
new file mode 100644
index 0000000..63c56e0
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs
@@ -0,0 +1,77 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Nonemm.Contests;
+using Nonemm.Core;
+using Nonemm.Storage;
+
+namespace Nonemm.App.Dialogs;
+
+/// Picks the contest and the entry categories the sponsor asks for in the
+/// Cabrillo header.
+public sealed partial class ContestSetupDialog : Window
+{
+ private readonly AppSession session;
+
+ public ContestSetupDialog(AppSession session)
+ {
+ this.session = session;
+ InitializeComponent();
+ ContestBox.ItemsSource = session.Registry.Choices.Select(c => c.DisplayName).ToList();
+ ContestBox.SelectedIndex = 0;
+ ContestBox.SelectionChanged += (_, _) => ShowChosen();
+ ModeBox.ItemsSource = new[] { "CW", "SSB", "RTTY", "MIXED" };
+ ModeBox.SelectedIndex = 0;
+ OperatorBox.ItemsSource = new[] { "SINGLE-OP", "MULTI-OP", "CHECKLIST" };
+ OperatorBox.SelectedIndex = 0;
+ BandBox.ItemsSource = new[] { "ALL", "160M", "80M", "40M", "20M", "15M", "10M" };
+ BandBox.SelectedIndex = 0;
+ PowerBox.ItemsSource = new[] { "HIGH", "LOW", "QRP" };
+ PowerBox.SelectedIndex = 0;
+ AssistedBox.ItemsSource = new[] { "NON-ASSISTED", "ASSISTED" };
+ AssistedBox.SelectedIndex = 0;
+ TransmitterBox.ItemsSource = new[] { "ONE", "TWO", "LIMITED", "UNLIMITED", "SWL" };
+ TransmitterBox.SelectedIndex = 0;
+ OperatorsBox.Text = session.Settings.Station.Callsign;
+ ShowChosen();
+ }
+
+
+ private ContestChoice Chosen =>
+ session.Registry.Choices[Math.Max(0, ContestBox.SelectedIndex)];
+
+ private void ShowChosen()
+ {
+ Contest contest = Chosen.Create(ModeOf());
+ ExchangeBox.Text = contest.SentExchangeFor(session.Settings.Station.ToStationInfo());
+ HintText.Text = $"Cabrillo name {contest.CabrilloName} · exchange " +
+ string.Join(", ", contest.ExchangeFieldsFor(session.Settings.Station.ToStationInfo())
+ .Select(f => f.Label));
+ }
+
+ private ModeCategory ModeOf() => (ModeBox.SelectedItem as string) switch
+ {
+ "SSB" => ModeCategory.Phone,
+ "RTTY" => ModeCategory.Digital,
+ _ => ModeCategory.Cw,
+ };
+
+ private void OnStart(object? sender, RoutedEventArgs e) => Close(new ContestInstance
+ {
+ ContestNumber = 0,
+ ContestName = Chosen.Name,
+ StartDate = DateTime.UtcNow,
+ SentExchange = ExchangeBox.Text ?? "",
+ OperatorCategory = Text(OperatorBox),
+ BandCategory = Text(BandBox),
+ PowerCategory = Text(PowerBox),
+ ModeCategory = Text(ModeBox),
+ AssistedCategory = Text(AssistedBox),
+ TransmitterCategory = Text(TransmitterBox),
+ OverlayCategory = OverlayBox.Text ?? "",
+ Operators = OperatorsBox.Text ?? "",
+ });
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
+
+ private static string Text(ComboBox box) => box.SelectedItem as string ?? "";
+}
diff --git a/src/Nonemm.App/Dialogs/NetworkDialog.axaml b/src/Nonemm.App/Dialogs/NetworkDialog.axaml
new file mode 100644
index 0000000..6899385
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/NetworkDialog.axaml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Dialogs/NetworkDialog.axaml.cs b/src/Nonemm.App/Dialogs/NetworkDialog.axaml.cs
new file mode 100644
index 0000000..24cb236
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/NetworkDialog.axaml.cs
@@ -0,0 +1,37 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Nonemm.App.Configuration;
+
+namespace Nonemm.App.Dialogs;
+
+public sealed partial class NetworkDialog : Window
+{
+ private readonly Settings settings;
+
+ public NetworkDialog(Settings settings)
+ {
+ this.settings = settings;
+ InitializeComponent();
+ NameBox.Text = settings.NetworkStationName.Length > 0
+ ? settings.NetworkStationName
+ : Environment.MachineName;
+ PortBox.Text = settings.NetworkPort.ToString();
+ PeersBox.Text = string.Join("\n", settings.NetworkPeers);
+ EnabledBox.IsChecked = settings.NetworkEnabled;
+ }
+
+
+ private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
+ {
+ NetworkStationName = (NameBox.Text ?? "").Trim(),
+ NetworkPort = int.TryParse(PortBox.Text, out int port) ? port : 12060,
+ NetworkPeers = (PeersBox.Text ?? "")
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .Select(l => l.Trim())
+ .Where(l => l.Length > 0)
+ .ToList(),
+ NetworkEnabled = EnabledBox.IsChecked == true,
+ });
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
+}
diff --git a/src/Nonemm.App/Dialogs/RadioDialog.axaml b/src/Nonemm.App/Dialogs/RadioDialog.axaml
new file mode 100644
index 0000000..e07b037
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/RadioDialog.axaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Dialogs/RadioDialog.axaml.cs b/src/Nonemm.App/Dialogs/RadioDialog.axaml.cs
new file mode 100644
index 0000000..39420a5
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/RadioDialog.axaml.cs
@@ -0,0 +1,29 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Nonemm.App.Configuration;
+
+namespace Nonemm.App.Dialogs;
+
+public sealed partial class RadioDialog : Window
+{
+ private readonly Settings settings;
+
+ public RadioDialog(Settings settings)
+ {
+ this.settings = settings;
+ InitializeComponent();
+ HostBox.Text = settings.RigctldHost;
+ PortBox.Text = settings.RigctldPort.ToString();
+ EnabledBox.IsChecked = settings.RadioEnabled;
+ }
+
+
+ private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
+ {
+ RigctldHost = (HostBox.Text ?? "127.0.0.1").Trim(),
+ RigctldPort = int.TryParse(PortBox.Text, out int port) ? port : 4532,
+ RadioEnabled = EnabledBox.IsChecked == true,
+ });
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
+}
diff --git a/src/Nonemm.App/Dialogs/StationDialog.axaml b/src/Nonemm.App/Dialogs/StationDialog.axaml
new file mode 100644
index 0000000..ed5db46
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/StationDialog.axaml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Dialogs/StationDialog.axaml.cs b/src/Nonemm.App/Dialogs/StationDialog.axaml.cs
new file mode 100644
index 0000000..3df602a
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/StationDialog.axaml.cs
@@ -0,0 +1,54 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Nonemm.App.Configuration;
+
+namespace Nonemm.App.Dialogs;
+
+/// The operator's own station: what goes in the sent exchange and what contest
+/// rules compare a worked station against.
+public sealed partial class StationDialog : Window
+{
+ public StationDialog(StoredStation station)
+ {
+ InitializeComponent();
+ CallsignBox.Text = station.Callsign;
+ CqZoneBox.Text = station.CqZone.ToString();
+ ItuZoneBox.Text = station.ItuZone.ToString();
+ ContinentBox.Text = station.Continent;
+ CountryBox.Text = station.CountryPrefix;
+ GridBox.Text = station.GridSquare;
+ StateBox.Text = station.State;
+ ProvinceBox.Text = station.Province;
+ SectionBox.Text = station.ArrlSection;
+ NameBox.Text = station.Name;
+ PowerBox.Text = station.Power;
+ ClubBox.Text = station.Club;
+ CheckBox.Text = station.Check.ToString();
+ PrecedenceBox.Text = station.Precedence;
+ CountyBox.Text = station.County;
+ }
+
+
+ private void OnSave(object? sender, RoutedEventArgs e) => Close(new StoredStation
+ {
+ Callsign = (CallsignBox.Text ?? "").Trim().ToUpperInvariant(),
+ CqZone = Number(CqZoneBox),
+ ItuZone = Number(ItuZoneBox),
+ Continent = (ContinentBox.Text ?? "").Trim().ToUpperInvariant(),
+ CountryPrefix = (CountryBox.Text ?? "").Trim().ToUpperInvariant(),
+ GridSquare = (GridBox.Text ?? "").Trim(),
+ State = (StateBox.Text ?? "").Trim().ToUpperInvariant(),
+ Province = (ProvinceBox.Text ?? "").Trim().ToUpperInvariant(),
+ ArrlSection = (SectionBox.Text ?? "").Trim().ToUpperInvariant(),
+ Name = (NameBox.Text ?? "").Trim(),
+ Power = (PowerBox.Text ?? "").Trim(),
+ Club = (ClubBox.Text ?? "").Trim(),
+ Check = Number(CheckBox),
+ Precedence = (PrecedenceBox.Text ?? "").Trim().ToUpperInvariant(),
+ County = (CountyBox.Text ?? "").Trim().ToUpperInvariant(),
+ });
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
+
+ private static int Number(TextBox box) => int.TryParse(box.Text, out int value) ? value : 0;
+}
diff --git a/src/Nonemm.App/Messages.cs b/src/Nonemm.App/Messages.cs
new file mode 100644
index 0000000..2791456
--- /dev/null
+++ b/src/Nonemm.App/Messages.cs
@@ -0,0 +1,22 @@
+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.
+public static class Messages
+{
+ public static readonly IReadOnlyList<(string Key, string Label, string Text)> Defaults =
+ [
+ ("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", ""),
+ ];
+}
diff --git a/src/Nonemm.App/Nonemm.App.csproj b/src/Nonemm.App/Nonemm.App.csproj
new file mode 100644
index 0000000..cce3bdb
--- /dev/null
+++ b/src/Nonemm.App/Nonemm.App.csproj
@@ -0,0 +1,31 @@
+
+
+ WinExe
+ net10.0
+ enable
+ app.manifest
+
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Program.cs b/src/Nonemm.App/Program.cs
new file mode 100644
index 0000000..4579e01
--- /dev/null
+++ b/src/Nonemm.App/Program.cs
@@ -0,0 +1,24 @@
+using Avalonia;
+using System;
+
+namespace Nonemm.App;
+
+class Program
+{
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+#if DEBUG
+ .WithDeveloperTools()
+#endif
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/src/Nonemm.App/Verdicts.cs b/src/Nonemm.App/Verdicts.cs
new file mode 100644
index 0000000..0a03e61
--- /dev/null
+++ b/src/Nonemm.App/Verdicts.cs
@@ -0,0 +1,37 @@
+using Avalonia.Media;
+using Nonemm.Contests;
+
+namespace Nonemm.App;
+
+/// One place decides what colour a station is, so the frame round the callsign
+/// box, a row in the check window and a spot on the bandmap cannot disagree.
+public static class Verdicts
+{
+ public static readonly IBrush Dupe = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B));
+ public static readonly IBrush NewMultiplier = new SolidColorBrush(Color.FromRgb(0x27, 0xAE, 0x60));
+ public static readonly IBrush Worth = new SolidColorBrush(Color.FromRgb(0x29, 0x80, 0xB9));
+ public static readonly IBrush Nothing = new SolidColorBrush(Color.FromRgb(0x7F, 0x8C, 0x8D));
+
+ public static IBrush Colour(Verdict? verdict) =>
+ verdict is null ? Nothing
+ : verdict.IsDupe ? Dupe
+ : verdict.IsNewMultiplier ? NewMultiplier
+ : verdict.Points > 0 ? Worth
+ : Nothing;
+
+ public static string Describe(Verdict? verdict)
+ {
+ if (verdict is null)
+ {
+ return "";
+ }
+ if (verdict.IsDupe)
+ {
+ return "dupe";
+ }
+ string points = $"{verdict.Points} {(verdict.Points == 1 ? "point" : "points")}";
+ return verdict.IsNewMultiplier
+ ? $"{points} · new {string.Join(", ", verdict.NewMultipliers.Select(m => m.Value))}"
+ : points;
+ }
+}
diff --git a/src/Nonemm.App/Windows/BandmapWindow.axaml b/src/Nonemm.App/Windows/BandmapWindow.axaml
new file mode 100644
index 0000000..7823677
--- /dev/null
+++ b/src/Nonemm.App/Windows/BandmapWindow.axaml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs
new file mode 100644
index 0000000..d3f2075
--- /dev/null
+++ b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs
@@ -0,0 +1,92 @@
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Nonemm.Contests;
+using Nonemm.Core;
+using Nonemm.Spotting;
+
+namespace Nonemm.App.Windows;
+
+/// The band as a list of stations in frequency order: cluster spots and every
+/// station this log has worked. Clicking one puts the radio on it with the
+/// callsign already in the entry window.
+public sealed partial class BandmapWindow : RefreshableWindow
+{
+ private readonly AppSession session;
+ private readonly Action tune;
+
+ public BandmapWindow(AppSession session, Action tune)
+ {
+ this.session = session;
+ this.tune = tune;
+ InitializeComponent();
+ Spots.SelectionChanged += (_, _) => OnPicked();
+ ThisBandOnly.IsCheckedChanged += (_, _) => Refresh();
+ session.Bandmap.Changed += (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(Refresh);
+ Refresh();
+ }
+
+
+ public override void Refresh()
+ {
+ session.Bandmap.DropOlderThan(DateTime.UtcNow);
+ Band? band = session.Logging is null ? null : Bands.ForFrequency(session.Logging.Frequency);
+ BandText.Text = band?.Name ?? "no band";
+
+ IReadOnlyList spots = ThisBandOnly.IsChecked == true && band is not null
+ ? session.Bandmap.On(band)
+ : session.Bandmap.All();
+ Spots.ItemsSource = spots.Select(BuildRow).ToList();
+ }
+
+ private Control BuildRow(Spot spot)
+ {
+ StackPanel row = new() { Orientation = Orientation.Horizontal, Tag = spot };
+ row.Children.Add(new TextBlock
+ {
+ Text = spot.Frequency.Kilohertz.ToString("0.0").PadLeft(8),
+ FontFamily = new FontFamily("monospace"),
+ Opacity = 0.75,
+ Margin = new Avalonia.Thickness(0, 0, 8, 0),
+ });
+ row.Children.Add(new TextBlock
+ {
+ Text = spot.Call.Text,
+ FontFamily = new FontFamily("monospace"),
+ Foreground = Verdicts.Colour(VerdictFor(spot)),
+ });
+ row.Children.Add(new TextBlock
+ {
+ Text = spot.Source == SpotSource.Log ? " worked" : $" {spot.Spotter}",
+ FontSize = 11,
+ Opacity = 0.6,
+ Margin = new Avalonia.Thickness(6, 2, 0, 0),
+ });
+ return row;
+ }
+
+ private Verdict? VerdictFor(Spot spot)
+ {
+ if (session.Logging is null)
+ {
+ return null;
+ }
+ return session.Logging.Log.Judge(new Qso
+ {
+ Id = "",
+ TimestampUtc = DateTime.UtcNow,
+ Call = spot.Call,
+ Frequency = spot.Frequency,
+ Mode = session.Logging.Mode,
+ ContestName = session.Logging.Contest.Name,
+ });
+ }
+
+ private void OnPicked()
+ {
+ if (Spots.SelectedItem is Control { Tag: Spot spot })
+ {
+ tune(spot.Frequency, spot.Call.Text);
+ }
+ }
+}
diff --git a/src/Nonemm.App/Windows/CheckWindow.axaml b/src/Nonemm.App/Windows/CheckWindow.axaml
new file mode 100644
index 0000000..e8d226f
--- /dev/null
+++ b/src/Nonemm.App/Windows/CheckWindow.axaml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/Nonemm.App/Windows/CheckWindow.axaml.cs b/src/Nonemm.App/Windows/CheckWindow.axaml.cs
new file mode 100644
index 0000000..deeddc1
--- /dev/null
+++ b/src/Nonemm.App/Windows/CheckWindow.axaml.cs
@@ -0,0 +1,76 @@
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Nonemm.Session;
+
+namespace Nonemm.App.Windows;
+
+/// What the callsign being typed could be, a column per source. The columns
+/// stay apart because they answer different questions, and merged into one list
+/// they would all read as equally reliable.
+public sealed partial class CheckWindow : RefreshableWindow
+{
+ private readonly AppSession session;
+ private readonly Func typed;
+
+ public CheckWindow(AppSession session, Func typed)
+ {
+ this.session = session;
+ this.typed = typed;
+ InitializeComponent();
+ Refresh();
+ }
+
+
+ public override void Refresh()
+ {
+ Columns.Children.Clear();
+ if (session.Check is null)
+ {
+ return;
+ }
+ int at = 0;
+ foreach (CheckColumn column in session.Check.Columns(typed()))
+ {
+ Control panel = BuildColumn(column);
+ Grid.SetColumn(panel, at++);
+ Columns.Children.Add(panel);
+ }
+ }
+
+ private static Control BuildColumn(CheckColumn column)
+ {
+ StackPanel panel = new() { Margin = new Avalonia.Thickness(0, 0, 8, 0) };
+ panel.Children.Add(new TextBlock
+ {
+ Text = $"{Heading(column.Source)} {Count(column)}",
+ FontSize = 11,
+ Opacity = 0.7,
+ Margin = new Avalonia.Thickness(0, 0, 0, 4),
+ });
+ foreach (CheckCandidate candidate in column.Candidates)
+ {
+ panel.Children.Add(new TextBlock
+ {
+ Text = candidate.Call,
+ FontFamily = new FontFamily("monospace"),
+ FontSize = 14,
+ Foreground = Verdicts.Colour(candidate.Verdict),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ });
+ }
+ return new ScrollViewer { Content = panel };
+ }
+
+ /// Before anything is typed the heading says how many calls the source
+ /// holds; once it is offering candidates it says how many.
+ private static string Count(CheckColumn column) =>
+ column.Candidates.Count > 0 ? column.Candidates.Count.ToString() : $"({column.Held})";
+
+ private static string Heading(CheckSource source) => source switch
+ {
+ CheckSource.Log => "Log",
+ CheckSource.Database => "Master",
+ _ => "Bandmap",
+ };
+}
diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs
new file mode 100644
index 0000000..e547a09
--- /dev/null
+++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs
@@ -0,0 +1,328 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using Nonemm.App.Configuration;
+using Nonemm.App.Dialogs;
+using Nonemm.Core;
+using Nonemm.Formats.Adif;
+using Nonemm.Formats.Cabrillo;
+using Nonemm.Storage;
+
+namespace Nonemm.App.Windows;
+
+/// The entry window's menu. Each item does one thing and says what happened in
+/// the status line.
+public sealed partial class EntryWindow
+{
+ private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) };
+
+ private async void OnNewDatabase(object? sender, RoutedEventArgs e)
+ {
+ IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "New log database",
+ SuggestedFileName = "ham.s3db",
+ DefaultExtension = "s3db",
+ SuggestedStartLocation = await Folder(session.Paths.Databases),
+ });
+ OpenDatabaseAt(file);
+ }
+
+ private async void OnOpenDatabase(object? sender, RoutedEventArgs e)
+ {
+ IReadOnlyList files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Open log database",
+ AllowMultiple = false,
+ SuggestedStartLocation = await Folder(session.Paths.Databases),
+ });
+ OpenDatabaseAt(files.FirstOrDefault());
+ }
+
+ private void OpenDatabaseAt(IStorageFile? file)
+ {
+ if (file?.TryGetLocalPath() is not { } path)
+ {
+ return;
+ }
+ try
+ {
+ session.OpenDatabase(path);
+ Status($"log database {Path.GetFileName(path)}");
+ }
+ catch (Exception error) when (error is IOException or InvalidOperationException)
+ {
+ Status($"could not open {path}: {error.Message}");
+ }
+ }
+
+ private async void OnNewContest(object? sender, RoutedEventArgs e)
+ {
+ if (!HasDatabase())
+ {
+ return;
+ }
+ ContestSetupDialog dialog = new(session);
+ ContestInstance? chosen = await dialog.ShowDialog(this);
+ if (chosen is null)
+ {
+ return;
+ }
+ session.StartContest(chosen);
+ Status($"{chosen.ContestName} started");
+ }
+
+ private async void OnOpenContest(object? sender, RoutedEventArgs e)
+ {
+ if (!HasDatabase())
+ {
+ return;
+ }
+ ContestPickerDialog dialog = new(session.Store.Contests());
+ int? chosen = await dialog.ShowDialog(this);
+ if (chosen is not null)
+ {
+ session.OpenContest(chosen.Value);
+ }
+ }
+
+ private async void OnExportCabrillo(object? sender, RoutedEventArgs e)
+ {
+ if (Logging is null)
+ {
+ Status("no contest is open");
+ return;
+ }
+ IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Export Cabrillo",
+ SuggestedFileName = $"{session.Settings.Station.Callsign}.log",
+ });
+ if (file?.TryGetLocalPath() is not { } path)
+ {
+ return;
+ }
+ CabrilloWriter writer = new(Logging.Contest, Logging.Me);
+ CabrilloHeader header = new()
+ {
+ Contest = Logging.Contest.CabrilloName,
+ Callsign = Logging.Me.Callsign,
+ OperatorCategory = Logging.Instance.OperatorCategory,
+ AssistedCategory = Logging.Instance.AssistedCategory,
+ BandCategory = Logging.Instance.BandCategory,
+ ModeCategory = Logging.Instance.ModeCategory,
+ PowerCategory = Logging.Instance.PowerCategory,
+ StationCategory = Logging.Instance.StationCategory,
+ TransmitterCategory = Logging.Instance.TransmitterCategory,
+ OverlayCategory = Logging.Instance.OverlayCategory,
+ TimeCategory = Logging.Instance.TimeCategory,
+ ClaimedScore = Logging.Log.TotalScore,
+ Club = Logging.Me.Club,
+ Name = Logging.Me.Name,
+ Operators = Logging.Instance.Operators,
+ Soapbox = Logging.Instance.Soapbox,
+ };
+ await File.WriteAllTextAsync(path, writer.Write(header, Logging.Log.Qsos));
+ Status($"{Logging.Log.Qsos.Count} contacts written to {Path.GetFileName(path)}");
+ }
+
+ private async void OnExportAdif(object? sender, RoutedEventArgs e)
+ {
+ if (Logging is null)
+ {
+ Status("no contest is open");
+ return;
+ }
+ IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Export ADIF",
+ SuggestedFileName = $"{session.Settings.Station.Callsign}.adi",
+ });
+ if (file?.TryGetLocalPath() is not { } path)
+ {
+ return;
+ }
+ await File.WriteAllTextAsync(path, new AdifWriter(Logging.Me).Write(Logging.Log.Qsos));
+ Status($"{Logging.Log.Qsos.Count} contacts written to {Path.GetFileName(path)}");
+ }
+
+ private async void OnImportAdif(object? sender, RoutedEventArgs e)
+ {
+ if (Logging is null)
+ {
+ Status("no contest is open");
+ return;
+ }
+ IReadOnlyList files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Import ADIF",
+ AllowMultiple = false,
+ });
+ if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
+ {
+ return;
+ }
+ IReadOnlyList read = AdifReader.Read(
+ await File.ReadAllTextAsync(path),
+ Logging.Contest.Name,
+ Logging.Instance.ContestNumber);
+ foreach (Qso qso in read)
+ {
+ session.Store.Add(qso);
+ }
+ session.OpenContest(Logging.Instance.ContestNumber);
+ Status($"{read.Count} contacts read from {Path.GetFileName(path)}");
+ }
+
+ private void OnExit(object? sender, RoutedEventArgs e) => Close();
+
+ private void OnShowLog(object? sender, RoutedEventArgs e) => Show(() => new LogWindow(session));
+
+ private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session, () => Logging?.Entry.Call ?? ""));
+
+ private void OnShowBandmap(object? sender, RoutedEventArgs e) => Show(() => new BandmapWindow(session, Tune));
+
+ private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
+
+ private void OnShowPacket(object? sender, RoutedEventArgs e) => Show(() => new PacketWindow(session));
+
+ private async void OnStationSettings(object? sender, RoutedEventArgs e)
+ {
+ StationDialog dialog = new(session.Settings.Station);
+ StoredStation? updated = await dialog.ShowDialog(this);
+ if (updated is not null)
+ {
+ session.Save(session.Settings with { Station = updated });
+ if (Logging is not null)
+ {
+ session.OpenContest(Logging.Instance.ContestNumber);
+ }
+ }
+ }
+
+ private async void OnRadioSettings(object? sender, RoutedEventArgs e)
+ {
+ RadioDialog dialog = new(session.Settings);
+ Settings? updated = await dialog.ShowDialog(this);
+ if (updated is null)
+ {
+ return;
+ }
+ session.Save(updated);
+ if (updated.RadioEnabled)
+ {
+ session.ConnectRadio();
+ Status($"radio: rigctld at {updated.RigctldHost}:{updated.RigctldPort}");
+ }
+ else
+ {
+ session.DisconnectRadio();
+ Status("radio disconnected");
+ }
+ }
+
+ private async void OnClusterSettings(object? sender, RoutedEventArgs e)
+ {
+ ClusterDialog dialog = new(session.Settings);
+ Settings? updated = await dialog.ShowDialog(this);
+ if (updated is null)
+ {
+ return;
+ }
+ session.Save(updated);
+ if (updated.ClusterEnabled)
+ {
+ session.ConnectCluster();
+ Status($"cluster: {updated.ClusterHost}:{updated.ClusterPort}");
+ }
+ else
+ {
+ session.DisconnectCluster();
+ Status("cluster disconnected");
+ }
+ }
+
+ private async void OnNetworkSettings(object? sender, RoutedEventArgs e)
+ {
+ NetworkDialog dialog = new(session.Settings);
+ Settings? updated = await dialog.ShowDialog(this);
+ if (updated is not null)
+ {
+ session.Save(updated);
+ session.ApplyNetworkSettings();
+ Status(updated.NetworkEnabled ? "networked with the other stations" : "networking off");
+ }
+ }
+
+ private async void OnDownloadCountryFile(object? sender, RoutedEventArgs e) =>
+ await Download(
+ downloader => downloader.DownloadCountryFileAsync(session.Paths.CountryFile),
+ "country file");
+
+ private async void OnDownloadCallDatabase(object? sender, RoutedEventArgs e) =>
+ await Download(
+ downloader => downloader.DownloadCallDatabaseAsync(session.Paths.CallDatabaseFile),
+ "callsign database");
+
+ private void OnReloadSupportFiles(object? sender, RoutedEventArgs e)
+ {
+ session.ReloadSupportFiles();
+ Status($"country file {(session.Countries is null ? "missing" : "loaded")}, " +
+ $"{session.Calls.Count} callsigns");
+ }
+
+ private async Task Download(Func> fetch, string what)
+ {
+ Status($"fetching the {what}…");
+ try
+ {
+ string result = await fetch(new SupportFileDownloader(Http));
+ session.ReloadSupportFiles();
+ Status($"{what}: {result}");
+ }
+ catch (Exception error) when (error is InvalidOperationException or IOException)
+ {
+ Status(error.Message);
+ }
+ }
+
+ private void Tune(Frequency frequency, string call)
+ {
+ if (Logging is null)
+ {
+ return;
+ }
+ Logging.Tune(frequency);
+ _ = session.Radio?.TuneAsync(frequency);
+ Logging.Entry.Call = call;
+ SyncBoxes();
+ boxes[0].Focus();
+ Refresh();
+ }
+
+ private bool HasDatabase()
+ {
+ if (session.Settings.DatabasePath.Length > 0)
+ {
+ return true;
+ }
+ Status("open a log database first: File ▸ New Database");
+ return false;
+ }
+
+ private void Show(Func create) where T : Window
+ {
+ if (openWindows.TryGetValue(typeof(T), out Window? existing))
+ {
+ existing.Activate();
+ return;
+ }
+ T window = create();
+ openWindows[typeof(T)] = window;
+ window.Closed += (_, _) => openWindows.Remove(typeof(T));
+ window.Show(this);
+ }
+
+ private async Task Folder(string path) =>
+ await StorageProvider.TryGetFolderFromPathAsync(path);
+}
diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml
new file mode 100644
index 0000000..ec9f071
--- /dev/null
+++ b/src/Nonemm.App/Windows/EntryWindow.axaml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs
new file mode 100644
index 0000000..1d8867e
--- /dev/null
+++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs
@@ -0,0 +1,292 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Nonemm.Contests;
+using Nonemm.Core;
+using Nonemm.Session;
+
+namespace Nonemm.App.Windows;
+
+/// The window the operator types in. Every key that does something in a contest
+/// is handled here; the decisions behind them are in `LoggingSession`.
+public sealed partial class EntryWindow : Window
+{
+ private readonly AppSession session;
+ private readonly List boxes = [];
+ private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) };
+ private readonly Dictionary openWindows = [];
+ private bool updating;
+
+ public EntryWindow(AppSession session)
+ {
+ this.session = session;
+ InitializeComponent();
+ BuildFunctionKeys();
+ session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
+ session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(BuildEntryBoxes);
+ clock.Tick += (_, _) => UpdateClock();
+ clock.Start();
+ Opened += (_, _) => Reopen();
+ AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
+ }
+
+
+ private LoggingSession? Logging => session.Logging;
+
+ /// Reopens the database and contest the operator was last in.
+ private void Reopen()
+ {
+ try
+ {
+ int contestNumber = session.Settings.ContestNumber;
+ if (session.Settings.DatabasePath.Length > 0 && File.Exists(session.Settings.DatabasePath))
+ {
+ session.OpenDatabase(session.Settings.DatabasePath);
+ if (contestNumber > 0)
+ {
+ session.OpenContest(contestNumber);
+ }
+ }
+ }
+ catch (Exception e) when (e is IOException or InvalidOperationException or KeyNotFoundException)
+ {
+ Status($"could not reopen the last log: {e.Message}");
+ }
+ BuildEntryBoxes();
+ }
+
+ private void BuildEntryBoxes()
+ {
+ EntryGrid.Children.Clear();
+ EntryGrid.ColumnDefinitions.Clear();
+ boxes.Clear();
+ if (Logging is null)
+ {
+ ContestText.Text = "no contest — File ▸ New Contest";
+ Refresh();
+ return;
+ }
+
+ AddBox("Call", 12, 0);
+ for (int at = 0; at < Logging.Entry.Exchange.Count; at++)
+ {
+ ExchangeField field = Logging.Entry.Exchange[at];
+ AddBox(field.Label, field.Width, at + 1);
+ }
+ boxes[0].Focus();
+ Refresh();
+ }
+
+ private void AddBox(string label, int width, int index)
+ {
+ EntryGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
+ TextBlock caption = new() { Text = label };
+ caption.Classes.Add("label");
+ Grid.SetColumn(caption, index);
+ Grid.SetRow(caption, 0);
+ EntryGrid.Children.Add(caption);
+
+ TextBox box = new()
+ {
+ Width = (width * 13) + 20,
+ Margin = new Avalonia.Thickness(0, 0, 6, 0),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ };
+ box.Classes.Add("entry");
+ box.TextChanged += (_, _) => OnBoxChanged(index, box);
+ box.GotFocus += (_, _) => Logging?.Entry.FocusOn(index);
+ Grid.SetColumn(box, index);
+ Grid.SetRow(box, 1);
+ EntryGrid.Children.Add(box);
+ boxes.Add(box);
+ }
+
+ private void OnBoxChanged(int index, TextBox box)
+ {
+ if (updating || Logging is null)
+ {
+ return;
+ }
+ Logging.Entry[index] = box.Text ?? "";
+ Refresh();
+ }
+
+ private void OnWindowKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (Logging is null)
+ {
+ return;
+ }
+ switch (e.Key)
+ {
+ case Key.Space when FocusedIsEntryBox():
+ e.Handled = true;
+ MoveFocus(forward: true);
+ break;
+ case Key.Enter:
+ e.Handled = true;
+ OnEnter();
+ break;
+ case Key.Escape:
+ e.Handled = true;
+ Logging.Wipe();
+ SyncBoxes();
+ boxes[0].Focus();
+ break;
+ case Key.Tab when FocusedIsEntryBox():
+ e.Handled = true;
+ MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
+ break;
+ case Key.OemQuestion when e.KeyModifiers.HasFlag(KeyModifiers.Control):
+ e.Handled = true;
+ ToggleRun();
+ break;
+ }
+ }
+
+ private void OnEnter()
+ {
+ if (Logging is null)
+ {
+ return;
+ }
+ Frequency? qsy = Logging.PendingQsy();
+ if (qsy is not null)
+ {
+ Logging.Tune(qsy.Value);
+ _ = session.Radio?.TuneAsync(qsy.Value);
+ Logging.Entry.Call = "";
+ SyncBoxes();
+ boxes[0].Focus();
+ return;
+ }
+ if (!Logging.Entry.IsComplete)
+ {
+ MoveFocus(forward: true);
+ return;
+ }
+ try
+ {
+ Qso logged = Logging.LogContact();
+ Status($"logged {logged.Call} for {logged.Points} points");
+ }
+ catch (Exception e) when (e is InvalidOperationException or IOException)
+ {
+ Status($"could not log the contact: {e.Message}");
+ return;
+ }
+ SyncBoxes();
+ boxes[0].Focus();
+ }
+
+ private void MoveFocus(bool forward)
+ {
+ if (Logging is null || boxes.Count == 0)
+ {
+ return;
+ }
+ if (forward)
+ {
+ Logging.Entry.Advance();
+ }
+ else
+ {
+ Logging.Entry.Retreat();
+ }
+ boxes[Logging.Entry.Focus].Focus();
+ boxes[Logging.Entry.Focus].CaretIndex = boxes[Logging.Entry.Focus].Text?.Length ?? 0;
+ }
+
+ private bool FocusedIsEntryBox() => boxes.Any(b => b.IsFocused);
+
+ private void ToggleRun()
+ {
+ if (Logging is null)
+ {
+ return;
+ }
+ Logging.IsRunning = !Logging.IsRunning;
+ Refresh();
+ }
+
+ private void SyncBoxes()
+ {
+ if (Logging is null)
+ {
+ return;
+ }
+ updating = true;
+ for (int at = 0; at < boxes.Count; at++)
+ {
+ boxes[at].Text = Logging.Entry[at];
+ }
+ updating = false;
+ }
+
+ private void Refresh()
+ {
+ if (Logging is null)
+ {
+ VerdictText.Text = "";
+ return;
+ }
+ FrequencyText.Text = Logging.Frequency.Kilohertz.ToString("0.00");
+ ModeText.Text = Logging.Mode.Name;
+ RunText.Text = Logging.IsRunning ? "RUN" : "S&P";
+ RunBorder.Background = Logging.IsRunning ? Verdicts.Worth : new SolidColorBrush(Color.FromArgb(0x22, 0x80, 0x80, 0x80));
+ ContestText.Text = ContestLine();
+
+ Verdict? verdict = Logging.Verdict();
+ VerdictBorder.Background = Verdicts.Colour(verdict);
+ VerdictText.Text = VerdictLine(verdict);
+ VerdictText.Foreground = Brushes.White;
+ foreach (Window window in openWindows.Values)
+ {
+ (window as RefreshableWindow)?.Refresh();
+ }
+ }
+
+ private string ContestLine()
+ {
+ if (Logging is null)
+ {
+ return "";
+ }
+ string mults = string.Join(
+ " ",
+ Logging.Contest.MultiplierNames.Select(
+ (name, at) => $"{name} {Logging.Log.Tally.MultiplierCount(at + 1)}"));
+ return $"{Logging.Contest.DisplayName} · {Logging.Log.Tally.Qsos} Q · " +
+ $"{Logging.Log.Tally.Points} pts · {mults} · {Logging.Log.TotalScore:N0}";
+ }
+
+ private string VerdictLine(Verdict? verdict)
+ {
+ if (Logging is null || Logging.Entry.Call.Trim().Length == 0)
+ {
+ return $"sending {Logging?.SentNumber}";
+ }
+ string country = Logging.Country() is { } found
+ ? $"{found.Entity.Name} · {found.Continent} · CQ {found.CqZone} · ITU {found.ItuZone}"
+ : "unknown country";
+ return $"{country} — {Verdicts.Describe(verdict)}";
+ }
+
+ private void UpdateClock() => ClockText.Text = DateTime.UtcNow.ToString("HH:mm:ss") + "Z";
+
+ private void Status(string text) => StatusText.Text = text;
+
+ private void BuildFunctionKeys()
+ {
+ FunctionKeys.Children.Clear();
+ foreach ((string key, string label, _) in Messages.Defaults)
+ {
+ Button button = new() { Content = $"{key} {label}" };
+ button.Classes.Add("fkey");
+ FunctionKeys.Children.Add(button);
+ }
+ }
+}
diff --git a/src/Nonemm.App/Windows/LogRow.cs b/src/Nonemm.App/Windows/LogRow.cs
new file mode 100644
index 0000000..efd26da
--- /dev/null
+++ b/src/Nonemm.App/Windows/LogRow.cs
@@ -0,0 +1,59 @@
+using Avalonia.Media;
+using Nonemm.Contests;
+using Nonemm.Core;
+
+namespace Nonemm.App.Windows;
+
+/// One line of the log window. It carries the colour so the log is coloured by
+/// the same scorer as the entry window and the bandmap.
+public sealed record LogRow(Qso Qso, Verdict? Verdict)
+{
+ public string Time => Qso.TimestampUtc.ToString("MM-dd HH:mm");
+
+ public string Call => Qso.Call.Text;
+
+ public string Frequency => Qso.Frequency.Kilohertz.ToString("0.0");
+
+ public string Mode => Qso.Mode.Name;
+
+ public string Sent => $"{Qso.SentReport} {SentExchange()}".Trim();
+
+ public string Received => $"{Qso.ReceivedReport} {ReceivedExchange()}".Trim();
+
+ public string Country => Qso.CountryPrefix;
+
+ public int Points => Qso.Points;
+
+ public string Multipliers => string.Concat(
+ Qso.IsMultiplier1 ? "1" : "",
+ Qso.IsMultiplier2 ? "2" : "",
+ Qso.IsMultiplier3 ? "3" : "");
+
+ public string Operator => Qso.Operator;
+
+ public IBrush Colour => Verdicts.Colour(Verdict);
+
+ private string SentExchange() =>
+ Qso.SentNumber > 0 ? Qso.SentNumber.ToString() : "";
+
+ private string ReceivedExchange()
+ {
+ List parts = [];
+ if (Qso.ReceivedNumber > 0)
+ {
+ parts.Add(Qso.ReceivedNumber.ToString());
+ }
+ if (Qso.Zone > 0)
+ {
+ parts.Add(Qso.Zone.ToString());
+ }
+ foreach (string value in new[] { Qso.Section, Qso.Exchange1, Qso.Name, Qso.GridSquare, Qso.MiscText })
+ {
+ if (value.Length > 0)
+ {
+ parts.Add(value);
+ }
+ }
+ return string.Join(' ', parts);
+ }
+}
diff --git a/src/Nonemm.App/Windows/LogWindow.axaml b/src/Nonemm.App/Windows/LogWindow.axaml
new file mode 100644
index 0000000..d96e7c3
--- /dev/null
+++ b/src/Nonemm.App/Windows/LogWindow.axaml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Windows/LogWindow.axaml.cs b/src/Nonemm.App/Windows/LogWindow.axaml.cs
new file mode 100644
index 0000000..7bd49b2
--- /dev/null
+++ b/src/Nonemm.App/Windows/LogWindow.axaml.cs
@@ -0,0 +1,48 @@
+using Nonemm.Core;
+
+namespace Nonemm.App.Windows;
+
+/// The contacts of the contest in progress, newest last.
+public sealed partial class LogWindow : RefreshableWindow
+{
+ private readonly AppSession session;
+
+ public LogWindow(AppSession session)
+ {
+ this.session = session;
+ InitializeComponent();
+ Refresh();
+ }
+
+
+ public override void Refresh()
+ {
+ if (session.Logging is null)
+ {
+ Rows.ItemsSource = Array.Empty();
+ SummaryText.Text = "no contest is open";
+ return;
+ }
+ List rows = session.Logging.Log.Qsos
+ .Select(q => new LogRow(q, VerdictFor(q)))
+ .ToList();
+ Rows.ItemsSource = rows;
+ if (rows.Count > 0)
+ {
+ Rows.ScrollIntoView(rows[^1], null);
+ }
+ SummaryText.Text =
+ $"{session.Logging.Log.Tally.Qsos} contacts · {session.Logging.Log.Tally.Points} points · " +
+ $"{session.Logging.Log.Tally.TotalMultipliers} multipliers · score {session.Logging.Log.TotalScore:N0}";
+ }
+
+ /// A logged contact is coloured by what it turned out to be worth, not by
+ /// judging it again as if it were new.
+ private static Contests.Verdict VerdictFor(Qso qso) =>
+ new(
+ IsDupe: false,
+ Points: qso.Points,
+ NewMultipliers: qso.IsMultiplier1 || qso.IsMultiplier2 || qso.IsMultiplier3
+ ? [new Contests.Multiplier(1, "", "")]
+ : []);
+}
diff --git a/src/Nonemm.App/Windows/PacketWindow.axaml b/src/Nonemm.App/Windows/PacketWindow.axaml
new file mode 100644
index 0000000..c1ca332
--- /dev/null
+++ b/src/Nonemm.App/Windows/PacketWindow.axaml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Windows/PacketWindow.axaml.cs b/src/Nonemm.App/Windows/PacketWindow.axaml.cs
new file mode 100644
index 0000000..f6b71e6
--- /dev/null
+++ b/src/Nonemm.App/Windows/PacketWindow.axaml.cs
@@ -0,0 +1,61 @@
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+
+namespace Nonemm.App.Windows;
+
+/// The cluster node's traffic as it arrived, and a line to send commands on.
+public sealed partial class PacketWindow : RefreshableWindow
+{
+ private const int LinesKept = 500;
+
+ private readonly AppSession session;
+ private readonly Queue lines = new();
+
+ public PacketWindow(AppSession session)
+ {
+ this.session = session;
+ InitializeComponent();
+ if (session.Cluster is not null)
+ {
+ session.Cluster.LineArrived += OnLine;
+ }
+ Closed += (_, _) =>
+ {
+ if (session.Cluster is not null)
+ {
+ session.Cluster.LineArrived -= OnLine;
+ }
+ };
+ Refresh();
+ }
+
+
+ public override void Refresh() =>
+ StateText.Text = session.Cluster is null
+ ? "no cluster node — Config ▸ Cluster"
+ : session.Cluster.IsConnected
+ ? $"connected to {session.Settings.ClusterHost}:{session.Settings.ClusterPort}"
+ : "connecting…";
+
+ private void OnLine(object? sender, string line) => Dispatcher.UIThread.Post(() =>
+ {
+ lines.Enqueue(line);
+ while (lines.Count > LinesKept)
+ {
+ lines.Dequeue();
+ }
+ Traffic.Text = string.Join('\n', lines);
+ Scroller.ScrollToEnd();
+ Refresh();
+ });
+
+ private async void OnSend(object? sender, RoutedEventArgs e)
+ {
+ if (session.Cluster is null || CommandBox.Text is not { Length: > 0 } command)
+ {
+ return;
+ }
+ await session.Cluster.SendAsync(command);
+ CommandBox.Text = "";
+ }
+}
diff --git a/src/Nonemm.App/Windows/RefreshableWindow.cs b/src/Nonemm.App/Windows/RefreshableWindow.cs
new file mode 100644
index 0000000..b4da26b
--- /dev/null
+++ b/src/Nonemm.App/Windows/RefreshableWindow.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+
+namespace Nonemm.App.Windows;
+
+/// A window the entry window tells to redraw when the log changes.
+public abstract class RefreshableWindow : Window
+{
+ public abstract void Refresh();
+}
diff --git a/src/Nonemm.App/Windows/ScoreWindow.axaml b/src/Nonemm.App/Windows/ScoreWindow.axaml
new file mode 100644
index 0000000..f8a7068
--- /dev/null
+++ b/src/Nonemm.App/Windows/ScoreWindow.axaml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Windows/ScoreWindow.axaml.cs b/src/Nonemm.App/Windows/ScoreWindow.axaml.cs
new file mode 100644
index 0000000..700d78f
--- /dev/null
+++ b/src/Nonemm.App/Windows/ScoreWindow.axaml.cs
@@ -0,0 +1,87 @@
+using Avalonia.Controls;
+using Nonemm.Core;
+
+namespace Nonemm.App.Windows;
+
+/// Contacts, points and multipliers band by band.
+public sealed partial class ScoreWindow : RefreshableWindow
+{
+ private readonly AppSession session;
+
+ public ScoreWindow(AppSession session)
+ {
+ this.session = session;
+ InitializeComponent();
+ Refresh();
+ }
+
+
+ public override void Refresh()
+ {
+ Table.Children.Clear();
+ Table.ColumnDefinitions.Clear();
+ Table.RowDefinitions.Clear();
+ if (session.Logging is null)
+ {
+ TotalText.Text = "no contest is open";
+ return;
+ }
+
+ List bands = session.Logging.Log.Qsos
+ .Select(q => q.Band)
+ .OfType()
+ .Distinct()
+ .OrderBy(b => b.MegahertzLabel)
+ .ToList();
+
+ foreach (string _ in new[] { "band", "qsos", "points", "mults" })
+ {
+ Table.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ }
+ AddRow(0, "Band", "QSOs", "Points", "Mults", header: true);
+
+ int row = 1;
+ foreach (Band band in bands)
+ {
+ IReadOnlyList onBand = session.Logging.Log.Qsos.Where(q => q.Band == band).ToList();
+ AddRow(
+ row++,
+ band.Name,
+ onBand.Count.ToString(),
+ onBand.Sum(q => q.Points).ToString(),
+ onBand.Sum(MultiplierCount).ToString());
+ }
+ AddRow(
+ row,
+ "Total",
+ session.Logging.Log.Tally.Qsos.ToString(),
+ session.Logging.Log.Tally.Points.ToString(),
+ session.Logging.Log.Tally.TotalMultipliers.ToString(),
+ header: true);
+
+ TotalText.Text = $"Claimed score {session.Logging.Log.TotalScore:N0}";
+ }
+
+ private static int MultiplierCount(Qso qso) =>
+ (qso.IsMultiplier1 ? 1 : 0) + (qso.IsMultiplier2 ? 1 : 0) + (qso.IsMultiplier3 ? 1 : 0);
+
+ private void AddRow(int row, string band, string qsos, string points, string mults, bool header = false)
+ {
+ Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ string[] cells = [band, qsos, points, mults];
+ for (int column = 0; column < cells.Length; column++)
+ {
+ TextBlock text = new()
+ {
+ Text = cells[column],
+ FontFamily = new Avalonia.Media.FontFamily("monospace"),
+ FontSize = 13,
+ Opacity = header ? 1.0 : 0.85,
+ Margin = new Avalonia.Thickness(0, 2, 8, 2),
+ };
+ Grid.SetRow(text, row);
+ Grid.SetColumn(text, column);
+ Table.Children.Add(text);
+ }
+ }
+}
diff --git a/src/Nonemm.App/app.manifest b/src/Nonemm.App/app.manifest
new file mode 100644
index 0000000..0f3d738
--- /dev/null
+++ b/src/Nonemm.App/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.Network/ContactMessage.cs b/src/Nonemm.Network/ContactMessage.cs
new file mode 100644
index 0000000..5ae6ff0
--- /dev/null
+++ b/src/Nonemm.Network/ContactMessage.cs
@@ -0,0 +1,158 @@
+using System.Globalization;
+using System.Xml.Linq;
+using Nonemm.Core;
+
+namespace Nonemm.Network;
+
+/// One contact as N1MM broadcasts it: a `contactinfo` XML document. Writing
+/// N1MM's own message means a Nonemm station and an N1MM station can sit on the
+/// same network and see each other's contacts.
+public static class ContactMessage
+{
+ /// N1MM sends the frequencies in units of ten hertz.
+ private const long FrequencyUnit = 10;
+
+ public static string Write(Qso qso, string myCallsign, string stationName)
+ {
+ XElement root = new(
+ "contactinfo",
+ new XElement("app", "Nonemm"),
+ new XElement("contestname", qso.ContestName),
+ new XElement("contestnr", qso.ContestNumber),
+ new XElement("timestamp", qso.TimestampUtc.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)),
+ new XElement("mycall", myCallsign),
+ new XElement("band", qso.Band?.MegahertzLabel ?? 0),
+ new XElement("rxfreq", qso.Frequency.Hertz / FrequencyUnit),
+ new XElement("txfreq", (qso.QsxFrequency.Hertz == 0 ? qso.Frequency.Hertz : qso.QsxFrequency.Hertz) / FrequencyUnit),
+ new XElement("operator", qso.Operator),
+ new XElement("mode", qso.Mode.Name),
+ new XElement("call", qso.Call.Text),
+ new XElement("countryprefix", qso.CountryPrefix),
+ new XElement("wpxprefix", qso.WpxPrefix),
+ new XElement("stationprefix", qso.StationPrefix),
+ new XElement("continent", qso.Continent),
+ new XElement("snt", qso.SentReport),
+ new XElement("sntnr", qso.SentNumber),
+ new XElement("rcv", qso.ReceivedReport),
+ new XElement("rcvnr", qso.ReceivedNumber),
+ new XElement("gridsquare", qso.GridSquare),
+ new XElement("exchange1", qso.Exchange1),
+ new XElement("section", qso.Section),
+ new XElement("comment", qso.Comment),
+ new XElement("qth", qso.Qth),
+ new XElement("name", qso.Name),
+ new XElement("power", qso.Power),
+ new XElement("misctext", qso.MiscText),
+ new XElement("zone", qso.Zone),
+ new XElement("prec", qso.Precedence),
+ new XElement("ck", qso.Check),
+ new XElement("ismultiplier1", qso.IsMultiplier1 ? 1 : 0),
+ new XElement("ismultiplier2", qso.IsMultiplier2 ? 1 : 0),
+ new XElement("ismultiplier3", qso.IsMultiplier3 ? 1 : 0),
+ new XElement("points", qso.Points),
+ new XElement("radionr", qso.RadioNumber),
+ new XElement("RoverLocation", qso.RoverLocation),
+ new XElement("RadioInterfaced", qso.IsRadioInterfaced ? 1 : 0),
+ new XElement("NetworkedCompNr", qso.NetworkedComputerNumber),
+ new XElement("IsOriginal", qso.IsOriginal),
+ new XElement("NetBiosName", stationName),
+ new XElement("IsRunQSO", qso.IsRunQso ? 1 : 0),
+ new XElement("StationName", stationName),
+ new XElement("ID", qso.Id),
+ new XElement("IsClaimedQso", qso.IsClaimed ? 1 : 0));
+ return root.ToString();
+ }
+
+ /// Null for a message that is not a contact, which is how the other N1MM
+ /// message kinds on the same port are passed over.
+ public static Qso? Read(string xml)
+ {
+ XElement root;
+ try
+ {
+ root = XElement.Parse(xml);
+ }
+ catch (System.Xml.XmlException)
+ {
+ return null;
+ }
+ if (root.Name.LocalName != "contactinfo")
+ {
+ return null;
+ }
+ string call = Text(root, "call");
+ if (call.Length == 0)
+ {
+ return null;
+ }
+ return new Qso
+ {
+ Id = Text(root, "ID") is { Length: > 0 } id ? id : Qso.NewId(),
+ TimestampUtc = Timestamp(root),
+ Call = Callsign.Parse(call),
+ Frequency = Frequency.FromHertz(Integer(root, "rxfreq") * FrequencyUnit),
+ QsxFrequency = Frequency.FromHertz(Integer(root, "txfreq") * FrequencyUnit),
+ Mode = Modes.Parse(Text(root, "mode")) ?? Modes.Cw,
+ ContestName = Text(root, "contestname"),
+ ContestNumber = (int)Integer(root, "contestnr"),
+ SentReport = Text(root, "snt"),
+ ReceivedReport = Text(root, "rcv"),
+ SentNumber = (int)Integer(root, "sntnr"),
+ ReceivedNumber = (int)Integer(root, "rcvnr"),
+ Zone = (int)Integer(root, "zone"),
+ Check = (int)Integer(root, "ck"),
+ Precedence = Text(root, "prec"),
+ Section = Text(root, "section"),
+ Exchange1 = Text(root, "exchange1"),
+ MiscText = Text(root, "misctext"),
+ Comment = Text(root, "comment"),
+ Name = Text(root, "name"),
+ Qth = Text(root, "qth"),
+ Power = Text(root, "power"),
+ GridSquare = Text(root, "gridsquare"),
+ RoverLocation = Text(root, "RoverLocation"),
+ CountryPrefix = Text(root, "countryprefix"),
+ StationPrefix = Text(root, "stationprefix"),
+ WpxPrefix = Text(root, "wpxprefix"),
+ Continent = Text(root, "continent"),
+ Points = (int)Integer(root, "points"),
+ IsMultiplier1 = Flag(root, "ismultiplier1"),
+ IsMultiplier2 = Flag(root, "ismultiplier2"),
+ IsMultiplier3 = Flag(root, "ismultiplier3"),
+ IsRunQso = Flag(root, "IsRunQSO"),
+ Operator = Text(root, "operator"),
+ RadioNumber = (int)Integer(root, "radionr"),
+ IsRadioInterfaced = Flag(root, "RadioInterfaced"),
+ NetworkedComputerNumber = (int)Integer(root, "NetworkedCompNr"),
+ StationName = Text(root, "StationName") is { Length: > 0 } station
+ ? station
+ : Text(root, "NetBiosName"),
+ // a contact that arrived over the network was made somewhere else
+ IsOriginal = false,
+ IsClaimed = Flag(root, "IsClaimedQso"),
+ };
+ }
+
+ private static string Text(XElement root, string name) =>
+ root.Element(name)?.Value.Trim() ?? "";
+
+ private static long Integer(XElement root, string name) =>
+ long.TryParse(Text(root, name), NumberStyles.Integer, CultureInfo.InvariantCulture, out long value)
+ ? value
+ : 0;
+
+ private static bool Flag(XElement root, string name)
+ {
+ string text = Text(root, name);
+ return text.Equals("true", StringComparison.OrdinalIgnoreCase) || text == "1";
+ }
+
+ private static DateTime Timestamp(XElement root) =>
+ DateTime.TryParse(
+ Text(root, "timestamp"),
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
+ out DateTime when)
+ ? when
+ : DateTime.UtcNow;
+}
diff --git a/src/Nonemm.Network/Nonemm.Network.csproj b/src/Nonemm.Network/Nonemm.Network.csproj
index b760144..4464042 100644
--- a/src/Nonemm.Network/Nonemm.Network.csproj
+++ b/src/Nonemm.Network/Nonemm.Network.csproj
@@ -1,5 +1,9 @@
+
+
+
+
net10.0
enable
diff --git a/src/Nonemm.Network/StationNetwork.cs b/src/Nonemm.Network/StationNetwork.cs
new file mode 100644
index 0000000..91d6e33
--- /dev/null
+++ b/src/Nonemm.Network/StationNetwork.cs
@@ -0,0 +1,105 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using Nonemm.Core;
+
+namespace Nonemm.Network;
+
+/// Keeps the stations of a multi-operator entry in step. Each contact is sent
+/// to every peer as it is logged, and contacts that arrive from a peer are
+/// handed to whoever owns the log.
+public sealed class StationNetwork : IDisposable
+{
+ private readonly int port;
+ private readonly string stationName;
+ private readonly IReadOnlyList peers;
+ private readonly CancellationTokenSource stopping = new();
+ private readonly UdpClient listener;
+ private readonly UdpClient sender = new();
+ private Task? loop;
+
+ public StationNetwork(int port, string stationName, IEnumerable peerAddresses)
+ {
+ this.port = port;
+ this.stationName = stationName;
+ peers = peerAddresses.Select(a => Endpoint(a, port)).OfType().ToList();
+ listener = new UdpClient(new IPEndPoint(IPAddress.Any, port));
+ sender.EnableBroadcast = true;
+ }
+
+ public string StationName => stationName;
+
+ public IReadOnlyList Peers => peers;
+
+ /// A contact another station logged. It arrives already scored; the log
+ /// works its points and multipliers out again from the rules.
+ public event EventHandler? ContactArrived;
+
+ public event EventHandler? Failed;
+
+ public void Start() => loop ??= Task.Run(() => ListenAsync(stopping.Token));
+
+ public async Task SendAsync(Qso qso, string myCallsign, CancellationToken cancellation = default)
+ {
+ byte[] message = Encoding.UTF8.GetBytes(ContactMessage.Write(qso, myCallsign, stationName));
+ foreach (IPEndPoint peer in Destinations())
+ {
+ try
+ {
+ await sender.SendAsync(message, peer, cancellation).ConfigureAwait(false);
+ }
+ catch (SocketException e)
+ {
+ Failed?.Invoke(this, $"could not reach {peer}: {e.Message}");
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ stopping.Cancel();
+ listener.Dispose();
+ sender.Dispose();
+ stopping.Dispose();
+ }
+
+ /// With no peers named, the contact goes out as a broadcast, which is how a
+ /// station that has just joined is found without configuring anything.
+ private IEnumerable Destinations() =>
+ peers.Count > 0 ? peers : [new IPEndPoint(IPAddress.Broadcast, port)];
+
+ private async Task ListenAsync(CancellationToken cancellation)
+ {
+ while (!cancellation.IsCancellationRequested)
+ {
+ try
+ {
+ UdpReceiveResult received = await listener.ReceiveAsync(cancellation).ConfigureAwait(false);
+ Qso? qso = ContactMessage.Read(Encoding.UTF8.GetString(received.Buffer));
+ if (qso is not null && qso.StationName != stationName)
+ {
+ ContactArrived?.Invoke(this, qso);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (SocketException e)
+ {
+ Failed?.Invoke(this, e.Message);
+ }
+ }
+ }
+
+ private static IPEndPoint? Endpoint(string address, int defaultPort)
+ {
+ string[] parts = address.Split(':');
+ if (!IPAddress.TryParse(parts[0].Trim(), out IPAddress? host))
+ {
+ return null;
+ }
+ int port = parts.Length > 1 && int.TryParse(parts[1], out int given) ? given : defaultPort;
+ return new IPEndPoint(host, port);
+ }
+}
diff --git a/src/Nonemm.Session/CheckCandidate.cs b/src/Nonemm.Session/CheckCandidate.cs
new file mode 100644
index 0000000..e3f8bbc
--- /dev/null
+++ b/src/Nonemm.Session/CheckCandidate.cs
@@ -0,0 +1,25 @@
+using Nonemm.Contests;
+using Nonemm.Core.Calls;
+
+namespace Nonemm.Session;
+
+/// One callsign the check window offers, with what the log says about it so it
+/// is coloured the same as everywhere else.
+public sealed record CheckCandidate(string Call, PartialMatchQuality Quality, Verdict? Verdict);
+
+/// Where a column of candidates came from. They stay apart because they answer
+/// different questions: the log is what this station copied itself, the
+/// database is a guess about the whole world, and the bandmap is somebody
+/// else's claim.
+public enum CheckSource
+{
+ Log,
+ Database,
+ Bandmap,
+}
+
+/// One column of the check window.
+public sealed record CheckColumn(
+ CheckSource Source,
+ int Held,
+ IReadOnlyList Candidates);
diff --git a/src/Nonemm.Session/CheckWindowSources.cs b/src/Nonemm.Session/CheckWindowSources.cs
new file mode 100644
index 0000000..c70d4d3
--- /dev/null
+++ b/src/Nonemm.Session/CheckWindowSources.cs
@@ -0,0 +1,86 @@
+using Nonemm.Contests;
+using Nonemm.Core;
+using Nonemm.Core.Calls;
+using Nonemm.Spotting;
+
+namespace Nonemm.Session;
+
+/// Answers what the callsign being typed could be, a column per source.
+public sealed class CheckWindowSources
+{
+ private readonly LoggingSession session;
+ private readonly CallDatabase database;
+ private readonly Bandmap bandmap;
+
+ public CheckWindowSources(LoggingSession session, CallDatabase database, Bandmap bandmap)
+ {
+ this.session = session;
+ this.database = database;
+ this.bandmap = bandmap;
+ }
+
+ public IReadOnlyList Columns(string typed, int limit = 24)
+ {
+ string query = typed.Trim().ToUpperInvariant();
+ return
+ [
+ Column(CheckSource.Log, LoggedCalls(), query, limit),
+ new CheckColumn(
+ CheckSource.Database,
+ database.Count,
+ query.Length < PartialMatching.ShortestUsefulQuery
+ ? []
+ : database.Matches(query, limit).Select(Judged).ToList()),
+ Column(CheckSource.Bandmap, SpottedCalls(), query, limit),
+ ];
+ }
+
+ private CheckColumn Column(
+ CheckSource source,
+ IReadOnlyList calls,
+ string query,
+ int limit)
+ {
+ if (query.Length < PartialMatching.ShortestUsefulQuery)
+ {
+ return new CheckColumn(source, calls.Count, []);
+ }
+ List found = [];
+ foreach (string call in calls)
+ {
+ PartialMatchQuality? quality = PartialMatching.Judge(query, call);
+ if (quality is not null)
+ {
+ found.Add(Judged(new PartialMatch(call, quality.Value)));
+ }
+ }
+ return new CheckColumn(
+ source,
+ calls.Count,
+ found.OrderBy(c => c.Quality).ThenBy(c => c.Call, StringComparer.Ordinal).Take(limit).ToList());
+ }
+
+ private CheckCandidate Judged(PartialMatch match) =>
+ new(match.Call, match.Quality, VerdictFor(match.Call));
+
+ /// What logging this call on the current band and mode would do.
+ private Verdict? VerdictFor(string call)
+ {
+ Qso candidate = new()
+ {
+ Id = "",
+ TimestampUtc = DateTime.UtcNow,
+ Call = Callsign.Parse(call),
+ Frequency = session.Frequency,
+ Mode = session.Mode,
+ ContestName = session.Contest.Name,
+ };
+ return session.Log.Judge(candidate);
+ }
+
+ private IReadOnlyList LoggedCalls() =>
+ session.Log.Qsos.Select(q => q.Call.Text).Distinct(StringComparer.Ordinal).ToList();
+
+ private IReadOnlyList SpottedCalls() =>
+ bandmap.All().Select(s => s.Call.Text).Distinct(StringComparer.Ordinal).ToList();
+}
diff --git a/src/Nonemm.Session/Nonemm.Session.csproj b/src/Nonemm.Session/Nonemm.Session.csproj
index e5b1bc7..f94e5d6 100644
--- a/src/Nonemm.Session/Nonemm.Session.csproj
+++ b/src/Nonemm.Session/Nonemm.Session.csproj
@@ -5,6 +5,7 @@
+