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 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 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(); 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; } /// 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; /// 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 MessageSender? 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; 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)); 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 OperatingPosition(Logging, number)); } activeRadio = Math.Min(activeRadio, positions.Count - 1); 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 if (contest.IsContact(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); // the socket thread must not touch the log: every other change to it is // made where the windows read it network.UpdateArrived += (_, update) => Dispatcher.UIThread.Post(() => TakeFromNetwork(update)); network.Start(); Changed?.Invoke(this, EventArgs.Empty); } /// What another station did to its log, applied to ours. Nothing goes back /// out to the network, and the contact is scored here from the rules /// instead of trusting what the sender put in the message. private void TakeFromNetwork(ContactUpdate update) { if (Logging is null) { return; } switch (update) { case ContactLogged logged: Logging.AddFromNetwork(logged.Qso); break; case ContactReplaced replaced: Logging.ReplaceFromNetwork(replaced.Qso, replaced.OldCall, replaced.OldTimestampUtc); break; case ContactDeleted deleted: Logging.DeleteFromNetwork(deleted.Id, deleted.Call, deleted.TimestampUtc); break; } } /// 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); } /// 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, }; WineBridgeChannel channel = new( Settings.DigitalBridgePath, Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null, Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine"); MmttyEngine started = new(channel, options); await started.StartAsync(); digital = started; digitalSender = new DigitalEngineSender(started); 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 (keyer is null) { throw new InvalidOperationException("there is no keyer"); } 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 keyer.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; } 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.ClusterLogonCall.Length > 0 ? 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.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty); cluster.Start(); } public void DisconnectCluster() { cluster?.Dispose(); cluster = 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); } } /// 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) => Logging is not null && Logging.Log.Judge(new Qso { Id = "", TimestampUtc = spot.AtUtc, Call = spot.Call, Frequency = spot.Frequency, Mode = Position?.Mode ?? Modes.Cw, ContestName = Logging.Contest.Name, }) is { IsDupe: true }; private void ApplySpotSettings() { spotFilter = Settings.SpotFilter.ToFilter(BandPlan, Settings.Station, Countries, Calls, History); Bandmap.Lifetime = TimeSpan.FromMinutes(Math.Max(1, Settings.SpotTimeoutMinutes)); } 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(); 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; } } }