using System.Collections.Concurrent; using Avalonia.Threading; using Nonemm.App.Configuration; using Nonemm.Contests; using Nonemm.Core; using Nonemm.Core.Calls; using Nonemm.Core.Country; using Nonemm.Digital; 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 partial class AppSession : IDisposable { /// How often this station tells the others how it is doing. N1MM sends its /// status on the same sort of interval; every second would read the log /// sixty times a minute for two numbers that barely move. private static readonly TimeSpan StatusInterval = TimeSpan.FromSeconds(5); private LogStore? store; private readonly List radios = []; private readonly List positions = []; private int activeRadio; private ClusterClient? cluster; private StationNetwork? network; private StationLink? link; /// What was last said to the other stations about where this one is, so it /// is said again only when it changes. private (Frequency Where, string Mode, bool Running, int Radio) announced; /// When the last `STATUS` went out. It is not sent every second: it carries /// the rates, which are read out of the log. private DateTime statusSent; private DispatcherTimer? announcing; private MessageSender? keyer; private VoiceKeyer? voice; private CancellationTokenSource? recordingStop; private SoundCard sound = new CommandSoundCard( CommandSoundCard.DefaultPlay, CommandSoundCard.DefaultRecord); private AlternatingCq? alternating; private MmttyEngine? digital; private DigitalEngineSender? digitalSender; private So2rBox? box; /// Which cluster spots reach the bandmap. Rebuilt whenever the settings or /// the support files change. private SpotFilter spotFilter = new(); private readonly Random jitter = new(); /// Spots wait here until the next flush. N1MM collects them the same way, /// because a skimmer feed sends more spots a minute than any window can /// usefully redraw. private readonly List arriving = []; private readonly Lock arrivingGuard = new(); private readonly Timer spotFlush; public AppSession(UserPaths paths, Settings settings) { Paths = paths; Settings = settings; paths.CreateFolders(); Recordings = new VoiceFiles(paths.Wav); Countries = LoadCountryFile(paths.CountryFile); Calls = LoadCallDatabase(paths.CallDatabaseFile); History = LoadCallHistory(settings.CallHistoryFile); Registry = ContestRegistry.FromFolder( paths.UserDefinedContests, out IReadOnlyList problems, paths.SupportFiles); UserDefinedContestProblems = problems; BandPlan = settings.ToBandPlan(); ApplySpotSettings(); spotFlush = new Timer(_ => FlushSpots(), null, SpotFlushInterval, SpotFlushInterval); } /// How often the spots that have arrived go onto the bandmap. N1MM's own /// queue is flushed once a second. private static readonly TimeSpan SpotFlushInterval = TimeSpan.FromSeconds(1); 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(); /// Where the operator was last on each band and mode, for the band buttons. public BandMemory BandMemory { get; } = new(); /// The contest in progress: one log and one score, however many radios. public ContestSession? Logging { get; private set; } /// The radio the operator is not on, or null at a one-radio station. The /// message macros that pass a station to the other band need it. public OperatingPosition? Other(OperatingPosition position) => positions.FirstOrDefault(p => p.RadioNumber != position.RadioNumber); /// 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 OperatingPosition? Position => activeRadio < positions.Count ? positions[activeRadio] : null; public CheckWindowSources? Check { get; private set; } /// Where a line of received text goes while the QTC window is taking /// traffic down on RTTY. The other station sends the header and the whole /// series in one transmission, and the operator clicks each line in the /// digital window rather than typing it, which is how N1MM works. The QTC /// window sets this while it is open and returns whether the line went /// into it. public Func? QtcLines { get; 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; /// The link to the other logging computers, or null when the operator has /// not turned it on. The network status window reads it. public StationLink? Link => link; public MessageSender? Keyer => keyer; /// What sends a message on that mode: the digital engine on a digital /// mode, the voice keyer on phone, the CW keyer otherwise. Everything that /// sends asks here, so a message always goes out the way the mode needs. public MessageSender? SenderFor(ModeCategory mode) => mode switch { ModeCategory.Digital when digitalSender is { IsReady: true } engine => engine, ModeCategory.Phone when voice is { IsReady: true } speaking => speaking, _ => keyer, }; /// What plays the recordings on phone. It is there whether or not there is /// a CW keyer: the recordings go out of the sound card, not the keying /// port. public VoiceKeyer? Voice => voice; /// The recordings under the `Wav` folder, for the voice keyer and for the /// window that records them. public VoiceFiles Recordings { get; } /// What plays and records a wav file. The voice keyer sends through it and /// the voice keyer window auditions through it. public SoundCard Sound => sound; /// The recording being made, or null. It is here rather than in the window /// that started it because there is one microphone: the voice keyer window /// and the entry window's Ctrl+Shift+F keys record into the same one. public string? Recording { get; private set; } /// Records over the file named under `Wav` until `StopRecording`, and /// answers with what to tell the operator: the file recorded, or why /// nothing was. public async Task RecordAsync(string name) { if (Recording is { } already) { return $"{already} is being recorded — stop that first"; } recordingStop = new CancellationTokenSource(); Recording = name; Changed?.Invoke(this, EventArgs.Empty); try { await sound.RecordAsync(Recordings.PathOf(name), recordingStop.Token) .ConfigureAwait(false); return $"recorded {name}"; } catch (Exception failure) when (failure is InvalidOperationException or IOException) { return failure.Message; } finally { recordingStop.Dispose(); recordingStop = null; Recording = null; Changed?.Invoke(this, EventArgs.Empty); } } /// Ends the recording, which is what saves it: the recorder is asked to /// stop so it can close the file it is writing. public void StopRecording() => recordingStop?.Cancel(); /// The digital modem, started by the digital window rather than at startup: /// it runs an engine under Wine, which is not something to do to an /// operator who is working CW. public MmttyEngine? Digital => digital; /// The same engine as something a macro can be sent through. public DigitalEngineSender? DigitalKeyer => digitalSender; /// 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; /// What a keyer had to say while a message was going out — a recording /// that would not play, most of the time. The entry window puts it in the /// status line: by then the caller has been answered and there is nothing /// left to throw at. public event EventHandler? Reported; public void Save(Settings settings) { Settings = settings; BandPlan = settings.ToBandPlan(); ApplySpotSettings(); 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, Settings.Station.ToStationInfo())); 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) { // whoever was at the radio when the last contest was open is still // there, and every contact he logs carries his callsign Operator = Settings.CurrentOperator, // N1MM stamps every contact with the computer that made it and the // station number of that computer, and a resync asks for the // contacts of one computer by name StationName = ComputerName, StationNumber = Settings.StationNumber, }; positions.Clear(); for (int number = 1; number <= PositionCount; number++) { positions.Add(new OperatingPosition(Logging, number) { CqLeaveHertz = Settings.CqLeaveHertz }); } activeRadio = Math.Min(activeRadio, positions.Count - 1); // the other stations refuse to resynchronise with a station on another // contest, and say so, which is only any use if they are told _ = link?.SendContestNameAsync(); // and the highest serial number taken on each band, so a station that // joins a contest already under way starts where the others are. N1MM // sends the same message at the same point _ = link?.SendSerialNumbersAsync(Logging.Numbers.Snapshot()); Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty); Logging.Logged += (_, qso) => { // a WAE QTC row is traffic about a station, not a station on the air; // a Run QSO is a station that called us, so it is not a spot to work if (contest.IsContact(qso) && !qso.IsRunQso) { 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); // and the same three to the other logging computers, which is a // different protocol on a different port Logging.Logged += (_, qso) => _ = link?.SendLoggedAsync(qso); // the number is announced when it is taken rather than when the contact // is logged, which is what keeps two operators off the same number Logging.Numbers.Reserved += (_, taken) => _ = link?.SendReservedNumberAsync(taken.Number, SomewhereOn(taken.Band)); Logging.Edited += (_, change) => _ = link?.SendEditedAsync(change.Qso, change.OldCall, change.OldTimestampUtc); Logging.Deleted += (_, qso) => _ = link?.SendDeletedAsync(qso); SpotRecentContacts(Logging.Log.Qsos); Check = new CheckWindowSources(positions[0], Calls, Bandmap); Available = new AvailableStations(positions[0], Bandmap); Save(Settings with { ContestNumber = contestNumber }); ContestChanged?.Invoke(this, EventArgs.Empty); SendAppInfo(); } /// A frequency on the band a number was taken on. The message carries a /// frequency because N1MM's does, and both ends read nothing off it but the /// band. private static Frequency SomewhereOn(Band? band) => band?.Low ?? Frequency.Zero; /// 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); // the socket thread must not touch the log: every other change to it is // made where the windows read it network.UpdateArrived += (_, update) => QueueFromNetwork(update); network.Start(); Changed?.Invoke(this, EventArgs.Empty); } /// Starts, restarts or stops the link to the other logging computers. It is /// separate from `ApplyNetworkSettings` because the two are separate /// networks: one carries XML to other programs, the other carries contacts /// to the other computers of this entry. /// What this computer is called on the network of the entry, which is also /// what every contact it logs is stamped with. public string ComputerName => Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName; public void ApplyStationLinkSettings() { announcing?.Stop(); link?.Dispose(); link = null; if (!Settings.StationLinkEnabled) { Changed?.Invoke(this, EventArgs.Empty); return; } link = new StationLink( ComputerName, Settings.StationLinkVersion, Settings.StationNumber, Settings.StationLinkPort) { Operator = Settings.Station.Callsign, }; // the socket thread must not touch the log: every other change to it is // made where the windows read it link.UpdateArrived += (_, update) => QueueFromNetwork(update); link.UpdateArrived += (_, update) => ForwardToPrograms(update); // the resync reads the log rather than being handed it, and another // station can ask for a rescore when its resync is done link.Log = new SessionContactLog(this); link.RescoreRequested += (_, _) => Dispatcher.UIThread.Post(() => Rescore(DateTime.MinValue)); link.SerialNumbersReset += (_, _) => Dispatcher.UIThread.Post(ResetSerialNumbers); link.SpotArrived += (_, shared) => Dispatcher.UIThread.Post(() => TakeSharedSpot(shared)); link.CallStacked += (_, stacked) => Dispatcher.UIThread.Post(() => TakeStackedCall(stacked)); link.NumberReserved += (_, taken) => Dispatcher.UIThread.Post(() => TakeReservedNumber(taken)); link.SerialNumbersArrived += (_, numbers) => Dispatcher.UIThread.Post(() => Logging?.Numbers.Merge(numbers)); foreach (string peer in Settings.StationLinkPeers) { if (Peer(peer) is var (name, address, port)) { link.AddStation(name, address, port); } } link.Start(); // where this station is has a dozen places it can change from — the // radio moving, a band button, a QSY typed into the callsign box, run // turning on — so it is read once a second and sent when it has // changed, rather than announced from each of them announcing?.Stop(); announced = default; statusSent = default; announcing = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; announcing.Tick += (_, _) => { AnnounceWhereIAm(); SayHowIAmDoing(); }; announcing.Start(); Changed?.Invoke(this, EventArgs.Empty); } /// Tells the other stations where this one is, if it has moved. The /// frequency goes out on its own — N1MM's `FREQ` — and everything else /// about the position rides in the `STATUS` that follows it. In a /// multi-single entry this is what the other operators watch: two stations /// on one band is a contact nobody can make. private void AnnounceWhereIAm() { if (link is null || Position is not { } position) { return; } (Frequency, string, bool, int) now = (position.Frequency, position.Mode.Name, position.IsRunning, position.RadioNumber); if (now == announced) { return; } bool moved = announced.Where != position.Frequency; announced = now; if (moved) { _ = link.SendFrequencyAsync(position.Frequency); } // the mode, run and the rest of it ride in `STATUS`, which is sent // again now rather than waiting out the rest of its interval statusSent = default; } /// True while this station is the second run radio of a two-radio /// position, which is N1MM's Run1/Run2 switch on the network status window. /// It goes out in every `STATUS` and is what the other stations read in /// their `R/M` column. public bool IsRun2 { get; set { field = value; statusSent = default; Changed?.Invoke(this, EventArgs.Empty); } } /// Starts the serial numbers again from the log: the next number becomes /// the highest one in it plus one. The contacts already logged keep the /// numbers they were sent with. Nothing is sent but `RESETQSONRS`, as in /// N1MM: each station works the same number out of its own copy of the log. public void ResetSerialNumbers() { if (Logging is not null) { Logging.RestartNumbers(); Changed?.Invoke(this, EventArgs.Empty); } } /// A serial number another station has taken. It is noted here so this /// station counts past it. A number at or below one this station has /// already used says the sender is behind, and it is sent the highest /// number for each band so it can count on from the right one, which is /// what N1MM answers with. private void TakeReservedNumber(ReservedNumberMessage taken) { if (Logging is not { } logging) { return; } if (!logging.Numbers.Note(taken.Number, Bands.ForFrequency(taken.Frequency))) { _ = link?.SendSerialNumbersAsync(logging.Numbers.Snapshot()); } Changed?.Invoke(this, EventArgs.Empty); } /// A callsign another station handed to this one. It goes on the call stack /// of the radio the operator is on, which is where the entry window takes /// them from. private void TakeStackedCall(StackedCall stacked) { if (Position is not { } position || stacked.Call.Length == 0) { return; } position.Stack.Add(stacked.Call, IsNewMultiplier(stacked)); Changed?.Invoke(this, EventArgs.Empty); } /// Whether a stacked call is worth a multiplier here, which is what decides /// where it goes in the stack. A call with no contact behind it is judged /// on the callsign alone. private bool IsNewMultiplier(StackedCall stacked) => Logging is not null && (stacked.Contact is { } contact ? Logging.Log.Judge(contact).NewMultipliers.Count > 0 : Logging.Log.Judge(new Qso { Id = "", TimestampUtc = DateTime.UtcNow, Call = Callsign.Parse(stacked.Call), Frequency = Position?.Frequency ?? Frequency.Zero, Mode = Position?.Mode ?? Modes.Cw, ContestName = Logging.Contest.Name, }).NewMultipliers.Count > 0); /// Scores the log again and writes the points and multiplier flags from /// `fromUtc` on back to the database. Returns how many rows were written. /// Another station asks for this at the end of a resync, and the rescore /// tab of the network status window asks for it by hand. public int Rescore(DateTime fromUtc) => Logging?.Rescore(fromUtc) ?? 0; /// The `STATUS` message: everything the other operators' network status /// windows show about this station, whether anything has changed or not. It /// goes out on N1MM's own interval, and the rates in it are read out of the /// log each time. private void SayHowIAmDoing() { if (link is null || Position is not { } position || DateTime.UtcNow - statusSent < StatusInterval) { return; } statusSent = DateTime.UtcNow; TimeSpan off = Logging?.Contest.MinimumOffTime ?? TimeSpan.FromMinutes(30); IReadOnlyList qsos = Logging?.Log.Qsos ?? []; _ = link.SendStatusAsync(new StationStatus( link.Me.PassFrequency, position.Frequency, position.IsRunning, link.Operator, position.Mode, Rates.PerHour(qsos, 10, DateTime.UtcNow, off), Rates.PerHour(qsos, 100, DateTime.UtcNow, off), Logging?.Instance.OperatorCategory ?? "", Logging?.Instance.TransmitterCategory ?? "", Countries?.Version ?? "", IsRun2)); } /// A station named by hand, written `RUN-PC@192.168.1.5` with `:port` on /// the end when that station is not on the usual one. Null for anything /// else, because a line the operator has half typed is not an address. private (string Name, string Address, int Port)? Peer(string text) { string[] parts = text.Split('@'); if (parts.Length != 2 || parts[0].Trim().Length == 0) { return null; } string[] host = parts[1].Split(':'); return ( parts[0].Trim(), host[0].Trim(), host.Length > 1 && int.TryParse(host[1], out int given) ? given : Settings.StationLinkPort); } /// Updates that have arrived from the network and not yet been applied. /// They are applied together on the window thread, so a resync that sends /// a few hundred contacts rescores the log and redraws the windows once /// per batch rather than once per contact. private readonly ConcurrentQueue fromNetwork = new(); private int networkDrainPosted; private void QueueFromNetwork(ContactUpdate update) { fromNetwork.Enqueue(update); if (Interlocked.Exchange(ref networkDrainPosted, 1) == 0) { Dispatcher.UIThread.Post(TakeQueuedFromNetwork); } } private void TakeQueuedFromNetwork() { // cleared first: an update that arrives while this runs posts a new drain Interlocked.Exchange(ref networkDrainPosted, 0); List batch = []; while (fromNetwork.TryDequeue(out ContactUpdate? update)) { batch.Add(update); } TakeFromNetwork(batch); } /// What other stations did to their logs, applied to ours in the order it /// arrived. Nothing goes back out to the network, and the contacts are /// scored here from the rules instead of trusting what the sender put in /// the message. private void TakeFromNetwork(IReadOnlyList updates) { if (Logging is null) { return; } List logged = []; foreach (ContactUpdate update in updates) { if (!Settings.LogQsosAtAllStations && update is not ContactsCleared) { continue; } if (update is ContactLogged contact) { logged.Add(contact.Qso); continue; } // a run of new contacts is applied before the update that follows // it, because that update may be a clear or an edit of one of them Logging.AddRangeFromNetwork(logged); SpotRecentContacts(logged); logged.Clear(); switch (update) { case ContactReplaced replaced: Logging.ReplaceFromNetwork(replaced.Qso, replaced.OldCall, replaced.OldTimestampUtc); break; case ContactDeleted deleted: Logging.DeleteFromNetwork(deleted.Id, deleted.Call, deleted.TimestampUtc); break; case ContactsCleared cleared: Logging.ClearFromNetwork(cleared.StationName, cleared.FromUtc, cleared.Length); break; } } Logging.AddRangeFromNetwork(logged); SpotRecentContacts(logged); } /// Puts the contacts of the last few minutes on the bandmap as local spots, /// so a restart does not lose the stations just worked. A station the /// bandmap already holds on that band keeps its spot. Nothing is sent to /// the other stations or the cluster. private void SpotRecentContacts(IEnumerable contacts) { if (Settings.RecentContactSpotMinutes <= 0) { return; } DateTime since = DateTime.UtcNow - TimeSpan.FromMinutes(Settings.RecentContactSpotMinutes); HashSet<(string, Band?)> held = [.. Bandmap.All().Select(s => (s.Call.Text, s.Band))]; List spots = []; foreach (Qso qso in contacts.Where(q => q.TimestampUtc >= since && !q.IsRunQso && Logging!.Contest.IsContact(q))) { Spot spot = new(qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log); if (held.Add((spot.Call.Text, spot.Band))) { spots.Add(spot); } } if (spots.Count > 0) { Bandmap.AddRange(spots); } } /// 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; voice?.Dispose(); voice = 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); } sound = new CommandSoundCard(Settings.VoicePlayCommand, Settings.VoiceRecordCommand); voice = new VoiceKeyer( sound, Recordings, // only cwdaemon has a way in to the keying port from here, and // only when the operator has not said the radio keys itself Settings.VoiceKeysPtt && keyer is CwDaemonSender daemon ? daemon.SetPttAsync : null); voice.Reported += (_, what) => Reported?.Invoke(this, what); // a CQ goes out through the keyer the radio's mode calls for, so // alternating CQ listens to both of them alternating = new AlternatingCq( keyer is null ? [voice] : [keyer, voice], CallCqOnAsync, TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs)); Changed?.Invoke(this, EventArgs.Empty); } /// Starts the digital engine under Wine and returns it. The engine that is /// already running is handed back rather than started again. public async Task StartDigitalAsync() { if (digital is { IsConnected: true } running) { return running; } StopDigital(); MmttyOptions options = new() { EnginePath = Settings.DigitalEnginePath, Number = ActiveRadioNumber, Window = Enum.TryParse(Settings.DigitalEngineWindow, ignoreCase: true, out EngineWindow window) ? window : EngineWindow.Normal, OnTop = Settings.DigitalEngineOnTop, PttPort = Settings.DigitalPttPort.Trim().Length > 0 ? Settings.DigitalPttPort : null, }; ProcessBridgeChannel channel = new( Settings.DigitalBridgePath, Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null, OperatingSystem.IsWindows() ? null : Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine", Paths.Diagnostics); MmttyEngine started = new(channel, options); await started.StartAsync(); digital = started; digitalSender = new DigitalEngineSender(started, Settings.DigitalBaud); Changed?.Invoke(this, EventArgs.Empty); return started; } public void StopDigital() { digitalSender?.Dispose(); digitalSender = null; digital?.Dispose(); digital = null; 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) { OperatingPosition position = positions.FirstOrDefault(p => p.RadioNumber == radioNumber) ?? throw new InvalidOperationException($"there is no radio {radioNumber}"); if (SenderFor(position.Mode.Category) is not { } sender) { throw new InvalidOperationException($"there is no keyer for {position.Mode.Name}"); } string template = Messages .For(position.Mode.Category, Settings.CwMessageFile, Settings.PhoneMessageFile) .Keys(position.IsRunning)[Esm.CallCq] .Message; if (template.Length == 0) { throw new InvalidOperationException("F1 has no message — Config ▸ Keyer and messages"); } MoveToRadio(radioNumber); await PointTransmitAtAsync(radioNumber).ConfigureAwait(false); await sender.SendAsync(MessageExpander.Expand(template, position, Other(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) { OperatingPosition? position = positions.FirstOrDefault(p => p.RadioNumber == moved.Number); if (position is null) { return; } TuneAndSpot(position, state.Frequency, state.Mode, state.TransmitFrequency); } private void DisposeRadios() { foreach (RigctldRadio open in radios) { open.Dispose(); } radios.Clear(); } private static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(v => v.Length > 0) ?? ""; /// The favourite the program connects to when it starts, if one is marked. public StoredClusterNode? AutoConnectNode => Settings.ClusterNodes.FirstOrDefault(n => n.AutoConnect); /// Connects to the favourite marked for it. Without one, nothing connects. public void ConnectAutoCluster() { if (AutoConnectNode is not { } node) { return; } Save(Settings with { ClusterHost = node.Host, ClusterPort = node.Port, ClusterPassword = node.Password, ClusterCommands = node.Commands, ClusterNodeLogonCall = node.LogonCall, }); ConnectCluster(); } public void ConnectCluster() { cluster?.Dispose(); SetSolar(null); cluster = new ClusterClient( Settings.ClusterHost, Settings.ClusterPort, FirstNonEmpty(Settings.ClusterNodeLogonCall, Settings.ClusterLogonCall, Settings.Station.Callsign), Settings.ClusterCommands, Settings.ClusterPassword, autoLogon: Settings.ClusterAutoLogon, keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes))); cluster.SpotArrived += (_, spot) => TakeSpot(spot); cluster.SolarArrived += (_, report) => SetSolar(report); cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty); cluster.Start(); } /// The latest solar weather the cluster sent, or null when it has sent /// none on this connection. public SolarReport? Solar { get; private set; } public event EventHandler? SolarChanged; private void SetSolar(SolarReport? report) { Solar = report; SolarChanged?.Invoke(this, EventArgs.Empty); } public void DisconnectCluster() { cluster?.Dispose(); cluster = null; SetSolar(null); Changed?.Invoke(this, EventArgs.Empty); } /// A spot the node sent. It waits for the next flush rather than going /// straight onto the bandmap. private void TakeSpot(Spot spot) { lock (arrivingGuard) { arriving.Add(spot); } } /// The spots that arrived since the last flush, filtered and moved about, /// put on the bandmap in one go. private void FlushSpots() { List batch; lock (arrivingGuard) { if (arriving.Count == 0) { return; } batch = [.. arriving]; arriving.Clear(); } List keeping = []; foreach (Spot spot in batch) { if (spotFilter.Accepts(spot)) { keeping.Add(Settings.RandomizeSpots ? SpotJitter.Shifted(spot, BandPlan, jitter, IsDupe(spot)) : spot); } } if (keeping.Count > 0) { Bandmap.AddRange(keeping); SendSpots(keeping); } } /// Whether the station has already been worked, which decides whether a /// spot is moved about: N1MM leaves a dupe where it is. private bool IsDupe(Spot spot) => JudgeSpot(spot) is { IsDupe: true }; /// What logging the station of a spot would do, or null with no contest open. private Verdict? JudgeSpot(Spot spot) => Logging?.Log.Judge(new Qso { Id = "", TimestampUtc = spot.AtUtc, Call = spot.Call, Frequency = spot.Frequency, Mode = Position?.Mode ?? Modes.Cw, ContestName = Logging.Contest.Name, }); private void ApplySpotSettings() { spotFilter = Settings.SpotFilter.ToFilter(BandPlan, Settings.Station, Countries, Calls, History); Bandmap.LifetimeFor = spot => TimeSpan.FromMinutes(Math.Max( 1, BandPlan.ModeAt(spot.Frequency) is ModeCategory.Cw or ModeCategory.Digital ? Settings.CwDigitalSpotTimeoutMinutes : Settings.PhoneSpotTimeoutMinutes)); foreach (OperatingPosition position in positions) { position.CqLeaveHertz = Settings.CqLeaveHertz; } } public void ReloadSupportFiles() { Countries = LoadCountryFile(Paths.CountryFile); Calls = LoadCallDatabase(Paths.CallDatabaseFile); History = LoadCallHistory(Settings.CallHistoryFile); Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _, Paths.SupportFiles); ApplySpotSettings(); if (Logging is not null) { OpenContest(Logging.Instance.ContestNumber); } Changed?.Invoke(this, EventArgs.Empty); } public void Dispose() { StopDigital(); spotFlush.Dispose(); DisposeRadios(); cluster?.Dispose(); announcing?.Stop(); broadcasting?.Stop(); data?.Dispose(); mirror?.Dispose(); network?.Dispose(); link?.Dispose(); alternating?.Dispose(); keyer?.Dispose(); voice?.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; } } }