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