Files
Nonemm/src/Nonemm.App/AppSession.cs
T
erikbrockoandClaude Sonnet 5.5 085584a281 Fix the crash on changing network settings, and apply resynced contacts in batches
ApplyNetworkSettings disposed the station link without clearing it, so
ApplyStationLinkSettings disposed it a second time and threw. Only the
second function touches the link now, and StationLink.Dispose can be
called twice.

Contacts from the network are queued and applied together on the window
thread. Each one used to rescore the whole log and redraw the log window,
which kept the window thread busy for minutes during a resync.

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

1072 lines
41 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 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));
}
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
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);
// 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);
Check = new CheckWindowSources(positions[0], Calls, Bandmap);
Available = new AvailableStations(positions[0], Bandmap);
Save(Settings with { ContestNumber = contestNumber });
ContestChanged?.Invoke(this, EventArgs.Empty);
}
/// 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);
// 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.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);
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);
}
/// 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;
}
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<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);
}
}
/// 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();
announcing?.Stop();
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;
}
}
}