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 readonly List positions = []; private int activeRadio; private ClusterClient? cluster; private StationNetwork? network; private MessageSender? keyer; private AlternatingCq? alternating; private So2rBox? box; public AppSession(UserPaths paths, Settings settings) { Paths = paths; Settings = settings; paths.CreateFolders(); Countries = LoadCountryFile(paths.CountryFile); Calls = LoadCallDatabase(paths.CallDatabaseFile); History = LoadCallHistory(settings.CallHistoryFile); Registry = ContestRegistry.FromFolder(paths.UserDefinedContests, out IReadOnlyList problems); UserDefinedContestProblems = problems; BandPlan = settings.ToBandPlan(); } public UserPaths Paths { get; } public Settings Settings { get; private set; } public ContestRegistry Registry { get; private set; } /// Where the bandmap paints the CW, digital and phone parts of a band. public BandPlan BandPlan { get; private set; } public IReadOnlyList UserDefinedContestProblems { get; } public CountryFile? Countries { get; private set; } public CallDatabase Calls { get; private set; } /// What was published about the stations in this contest. Empty when no /// file is chosen, and then nothing is filled in for the operator. public CallHistory History { get; private set; } = CallHistory.Empty; public Bandmap Bandmap { get; } = new(); /// The contest in progress: one log and one score, however many radios. public ContestSession? Logging { get; private set; } /// One per radio, in radio-number order. There is always at least one, so /// the program works with no radio connected. public IReadOnlyList Positions => positions; /// The radio the operator is on. public RadioPosition? Position => activeRadio < positions.Count ? positions[activeRadio] : null; public CheckWindowSources? Check { get; private set; } /// What is spotted and not worked yet. public AvailableStations? Available { 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 => Position?.RadioNumber ?? 1; public ClusterClient? Cluster => cluster; public StationNetwork? Network => network; public MessageSender? Keyer => keyer; /// Alternating CQ, or null while there is no keyer. It needs two radios to /// do anything, and a keyer that reports when a message has gone out. public AlternatingCq? Alternating => alternating; /// The SO2R box, or null when there is none and the operator switches the /// transmitter and the headphones by hand. public So2rBox? Box => box; /// True while both radios are in the headphones. public bool IsListeningToBoth { get; private set; } public event EventHandler? Changed; public event EventHandler? ContestChanged; public void Save(Settings settings) { Settings = settings; BandPlan = settings.ToBandPlan(); 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; Available = 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 ContestSession( Store, contest, instance, Settings.Station.ToStationInfo(), Countries, History); positions.Clear(); for (int number = 1; number <= PositionCount; number++) { positions.Add(new RadioPosition(Logging, number)); } activeRadio = Math.Min(activeRadio, positions.Count - 1); 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(positions[0], Calls, Bandmap); Available = new AvailableStations(positions[0], Bandmap); Save(Settings with { ContestNumber = contestNumber }); ContestChanged?.Invoke(this, EventArgs.Empty); } /// One entry position per radio, and one when there is no radio at all. private int PositionCount => Math.Max(1, Settings.Radios.Count(r => r.IsEnabled)); /// 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() { alternating?.Dispose(); alternating = null; 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); alternating = new AlternatingCq( keyer, CallCqOnAsync, TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs)); } 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(); } if (Logging is not null && positions.Count != PositionCount) { OpenContest(Logging.Instance.ContestNumber); return; } activeRadio = Math.Min(activeRadio, Math.Max(0, positions.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 (positions.Count < 2) { return; } MoveToRadio(positions[(activeRadio + 1) % positions.Count].RadioNumber); } /// Moves the operator to a radio by number. Alternating CQ calls this from /// the keyer's thread, so everything it raises is posted by the windows. public void MoveToRadio(int radioNumber) { int at = positions.FindIndex(p => p.RadioNumber == radioNumber); if (at < 0 || at == activeRadio) { return; } activeRadio = at; _ = FollowActiveRadioAsync(); Changed?.Invoke(this, EventArgs.Empty); ActiveRadioChanged?.Invoke(this, ActiveRadioNumber); } /// Moves to the radio and sends its CQ message, which is F1. Used by /// alternating CQ; the operator's own F1 goes through the entry window. private async Task CallCqOnAsync(int radioNumber) { RadioPosition position = positions.FirstOrDefault(p => p.RadioNumber == radioNumber) ?? throw new InvalidOperationException($"there is no radio {radioNumber}"); if (keyer is null) { throw new InvalidOperationException("there is no keyer"); } string template = Messages.For( position.Mode.Category, Settings.CwMessages, Settings.PhoneMessages)[0]; if (template.Length == 0) { throw new InvalidOperationException("F1 has no message — Config ▸ Keyer and messages"); } MoveToRadio(radioNumber); await PointTransmitAtAsync(radioNumber).ConfigureAwait(false); await keyer.SendAsync(MessageExpander.Expand(template, position)).ConfigureAwait(false); } /// Puts both radios in the headphones, or goes back to one. An operator /// listens to the second radio while the first is sending. public void ToggleListenToBoth() { IsListeningToBoth = !IsListeningToBoth; _ = FollowActiveRadioAsync(); Changed?.Invoke(this, EventArgs.Empty); } /// Points the box at the radio about to transmit. Called before keying, so /// a message never goes out of the radio the operator has just left. public Task PointTransmitAtAsync(int radioNumber) => box?.SetTransmitAsync(radioNumber) ?? Task.CompletedTask; /// Starts, restarts or stops the SO2R box, following what the settings say. public void ApplySo2rSettings() { box?.Dispose(); box = Settings.So2rBoxPort.Length > 0 ? OtrspBox.Open(Settings.So2rBoxPort) : null; _ = FollowActiveRadioAsync(); Changed?.Invoke(this, EventArgs.Empty); } private async Task FollowActiveRadioAsync() { if (box is null) { return; } try { await box.SetTransmitAsync(ActiveRadioNumber).ConfigureAwait(false); await box.SetReceiveAsync(ActiveRadioNumber, IsListeningToBoth).ConfigureAwait(false); } catch (Exception e) when (e is IOException or InvalidOperationException) { box.Dispose(); box = null; } } /// Raised when the operator moves to the other radio, so the entry windows /// can hand the keyboard over and the SO2R box can follow. public event EventHandler? ActiveRadioChanged; /// 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) { RadioPosition? position = positions.FirstOrDefault(p => p.RadioNumber == moved.Number); if (position is null) { return; } position.Tune(state.Frequency, state.Mode, state.TransmitFrequency); } 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); History = LoadCallHistory(Settings.CallHistoryFile); 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(); alternating?.Dispose(); keyer?.Dispose(); box?.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; } } /// A file that will not parse is reported by coming back empty rather than /// stopping the program before a contest. private static CallHistory LoadCallHistory(string path) { try { return path.Length > 0 && File.Exists(path) ? CallHistory.Parse(File.ReadAllText(path)) : CallHistory.Empty; } catch (Exception e) when (e is FormatException or IOException) { return CallHistory.Empty; } } 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; } } }