using Nonemm.App.Configuration; 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; 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 readonly List radios = []; private int activeRadio; private ClusterClient? cluster; private StationNetwork? network; private MessageSender? keyer; 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; } /// One entry per configured radio, in radio-number order. public IReadOnlyList Radios => radios; /// The radio the operator is on. Null when none is configured, and then /// frequency and mode stay wherever they were last typed. public Radio? Radio => activeRadio < radios.Count ? radios[activeRadio] : null; public int ActiveRadioNumber => Radio?.Number ?? 1; public ClusterClient? Cluster => cluster; public StationNetwork? Network => network; public MessageSender? Keyer => keyer; 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; } /// N1MM reads the `Contest` table when it opens a log, so the definition is /// written again whenever a contest is opened, not only when it is created. private void SaveDefinition(Contest contest) => Store.SaveContestDefinition(ContestDefinitions.For(contest)); 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)); SaveDefinition(contest); Logging = new LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries) { RadioNumber = ActiveRadioNumber, }; 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); Logging.Edited += (_, change) => _ = network?.SendEditAsync( change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc); Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(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.UpdateArrived += (_, update) => TakeFromNetwork(update); network.Start(); Changed?.Invoke(this, EventArgs.Empty); } /// What another station did to its log, applied to ours. The store is /// written directly rather than through `Logging`, so the change is not /// broadcast back out again. Points and multipliers are worked out here /// from the rules instead of trusting what the sender put in the message. private void TakeFromNetwork(ContactUpdate update) { if (Logging is null || store is null) { return; } int contestNumber = Logging.Instance.ContestNumber; switch (update) { case ContactLogged logged when !Logging.Log.Qsos.Any(q => q.Id == logged.Qso.Id): store.Add(logged.Qso with { ContestNumber = contestNumber, IsOriginal = false }); break; case ContactReplaced replaced: if (FindLocal(replaced.Qso.Id, replaced.OldCall, replaced.OldTimestampUtc) is not { } old) { return; } store.Update(replaced.Qso with { Id = old.Id, ContestNumber = contestNumber, IsOriginal = false, }); break; case ContactDeleted deleted: if (FindLocal(deleted.Id, deleted.Call, deleted.TimestampUtc) is not { } gone) { return; } store.Delete(gone.Id); break; default: return; } OpenContest(contestNumber); } /// N1MM keys a contact on its call and time, so a message from N1MM carries /// no id we would recognise. Fall back to that pair when the id misses. private Qso? FindLocal(string id, string call, DateTime timestampUtc) => Logging?.Log.Qsos.FirstOrDefault(q => id.Length > 0 && q.Id == id) ?? Logging?.Log.Qsos.FirstOrDefault(q => string.Equals(q.Call.Text, call, StringComparison.OrdinalIgnoreCase) && q.TimestampUtc == timestampUtc); /// 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); } /// Opens a connection per enabled radio. A second one makes the station /// SO2R: both are read, but only the one the operator is on drives the /// entry window. public void ConnectRadios() { DisposeRadios(); int number = 1; foreach (StoredRadio configured in Settings.Radios.Where(r => r.IsEnabled)) { RigctldRadio opened = new(configured.Host, configured.Port, number++); opened.Moved += (_, state) => RadioMoved(opened, state); opened.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty); radios.Add(opened); opened.Start(); } activeRadio = Math.Min(activeRadio, Math.Max(0, radios.Count - 1)); Changed?.Invoke(this, EventArgs.Empty); } public void DisconnectRadios() { DisposeRadios(); Changed?.Invoke(this, EventArgs.Empty); } /// Moves the operator to the other radio. The entry window follows where /// that radio is sitting, and contacts are logged against its number. public void SwapRadio() { if (radios.Count < 2) { return; } activeRadio = (activeRadio + 1) % radios.Count; if (Logging is not null) { Logging.RadioNumber = ActiveRadioNumber; if (Radio?.State is { } state) { Logging.Tune(state.Frequency, state.Mode, state.TransmitFrequency); } } Changed?.Invoke(this, EventArgs.Empty); } /// The radio the operator is not on still moves, and the bandmap shows it, /// but it must not drag the entry window off the contact being worked. private void RadioMoved(Radio moved, RadioState state) { if (moved.Number == ActiveRadioNumber) { Logging?.Tune(state.Frequency, state.Mode, state.TransmitFrequency); } else { Changed?.Invoke(this, EventArgs.Empty); } } private void DisposeRadios() { foreach (RigctldRadio open in radios) { open.Dispose(); } radios.Clear(); } public void ConnectCluster() { cluster?.Dispose(); cluster = new ClusterClient( Settings.ClusterHost, Settings.ClusterPort, Settings.Station.Callsign, Settings.ClusterCommands, Settings.ClusterPassword); 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() { DisposeRadios(); cluster?.Dispose(); network?.Dispose(); keyer?.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; } } }