Files
Nonemm/src/Nonemm.App/AppSession.cs
T
erikbrockoandClaude Sonnet 5.5 209c557112 Skip Run QSOs on the bandmap and refocus the callsign box after Alt+W
A QSO logged in Run mode is no longer added to the bandmap as a local
spot, including when recent contacts are re-spotted at startup.

Releasing Alt moves focus to the menu bar. Alt+W now sets a flag, and
the Alt key-up handler puts focus back on the callsign box.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:30:12 +00:00

1161 lines
44 KiB
C#

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<RigctldRadio> radios = [];
private readonly List<OperatingPosition> 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<Spot> 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<string> 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<string> 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<OperatingPosition> 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<string, bool>? 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<Radio> 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<string> 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<string>? 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<Qso> 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<ContactUpdate> 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<ContactUpdate> 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<ContactUpdate> updates)
{
if (Logging is null)
{
return;
}
List<Qso> 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<Qso> 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<Spot> 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<MmttyEngine> 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<int>? 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<Spot> batch;
lock (arrivingGuard)
{
if (arriving.Count == 0)
{
return;
}
batch = [.. arriving];
arriving.Clear();
}
List<Spot> 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;
}
}
}