N1MM's format, so a file published for a contest is read as it is: # comments, !!Order!! naming the columns of the lines after it, semicolons in preference to commas so user text can hold a comma, and a default column order for a file that names none. The directives that change what is stored are acted on; !!Order!! can appear again part way through, because these files are usually several files stuck together. Leaving the callsign box fills what the file knows into the exchange boxes that are still empty. A box with something in it is left alone: what the station actually sends beats what somebody published months ago. The zone comes from the CqZone or the ITUZone column depending on which the contest asks for, and a file with no Sect falls back to State. The check window's two missing columns are in. History lists matching calls from the file. Exchange is not about callsigns at all: it offers the values the exchange box with the cursor in it can hold, which is what N1MM's exchange pane does — it searches the contest's list of valid exchanges, not the log. CheckCandidate.Call is now Text, because an exchange value is not a call. The section-validating directives are read and passed over rather than acted on. Acting on them means holding N1MM's section lists and its rules about retired sections, and getting that wrong throws away good data. Running it caught what the unit tests could not: the exchange was filled and scored but the boxes on screen still looked empty, because nothing resynced them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
462 lines
16 KiB
C#
462 lines
16 KiB
C#
using Nonemm.App.Configuration;
|
|
using Nonemm.Contests;
|
|
using Nonemm.Core;
|
|
using Nonemm.Core.Calls;
|
|
using Nonemm.Core.Country;
|
|
using Nonemm.Keying;
|
|
using Nonemm.Network;
|
|
using Nonemm.Rig;
|
|
using Nonemm.Session;
|
|
using Nonemm.Spotting;
|
|
using Nonemm.Storage;
|
|
|
|
namespace Nonemm.App;
|
|
|
|
/// Everything the running program owns: the log store, the contest in progress,
|
|
/// the bandmap, the radio and the cluster. The windows read from this and one
|
|
/// `Changed` event tells them all to redraw.
|
|
public sealed class AppSession : IDisposable
|
|
{
|
|
private LogStore? store;
|
|
private readonly List<RigctldRadio> radios = [];
|
|
private readonly List<RadioPosition> positions = [];
|
|
private int activeRadio;
|
|
private ClusterClient? cluster;
|
|
private StationNetwork? network;
|
|
private MessageSender? keyer;
|
|
private So2rBox? box;
|
|
|
|
public AppSession(UserPaths paths, Settings settings)
|
|
{
|
|
Paths = paths;
|
|
Settings = settings;
|
|
paths.CreateFolders();
|
|
Countries = LoadCountryFile(paths.CountryFile);
|
|
Calls = LoadCallDatabase(paths.CallDatabaseFile);
|
|
History = LoadCallHistory(settings.CallHistoryFile);
|
|
Registry = ContestRegistry.FromFolder(paths.UserDefinedContests, out IReadOnlyList<string> problems);
|
|
UserDefinedContestProblems = problems;
|
|
}
|
|
|
|
public UserPaths Paths { get; }
|
|
|
|
public Settings Settings { get; private set; }
|
|
|
|
public ContestRegistry Registry { 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();
|
|
|
|
/// The contest in progress: one log and one score, however many radios.
|
|
public ContestSession? Logging { get; private set; }
|
|
|
|
/// One per radio, in radio-number order. There is always at least one, so
|
|
/// the program works with no radio connected.
|
|
public IReadOnlyList<RadioPosition> Positions => positions;
|
|
|
|
/// The radio the operator is on.
|
|
public RadioPosition? Position =>
|
|
activeRadio < positions.Count ? positions[activeRadio] : null;
|
|
|
|
public CheckWindowSources? Check { 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;
|
|
|
|
public MessageSender? Keyer => keyer;
|
|
|
|
/// 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;
|
|
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;
|
|
Save(Settings with
|
|
{
|
|
DatabasePath = path,
|
|
ContestNumber = sameFile ? Settings.ContestNumber : 0,
|
|
});
|
|
ContestChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public LogStore Store =>
|
|
store ?? throw new InvalidOperationException("no log database is open");
|
|
|
|
public ContestInstance StartContest(ContestInstance instance)
|
|
{
|
|
ContestInstance stored = Store.AddContest(instance);
|
|
OpenContest(stored.ContestNumber);
|
|
return stored;
|
|
}
|
|
|
|
/// N1MM reads the `Contest` table when it opens a log, so the definition is
|
|
/// written again whenever a contest is opened, not only when it is created.
|
|
private void SaveDefinition(Contest contest) =>
|
|
Store.SaveContestDefinition(ContestDefinitions.For(contest));
|
|
|
|
public void OpenContest(int contestNumber)
|
|
{
|
|
ContestInstance instance = Store.Contest(contestNumber)
|
|
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
|
|
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
|
|
SaveDefinition(contest);
|
|
Logging = new ContestSession(
|
|
Store, contest, instance, Settings.Station.ToStationInfo(), Countries, History);
|
|
positions.Clear();
|
|
for (int number = 1; number <= PositionCount; number++)
|
|
{
|
|
positions.Add(new RadioPosition(Logging, number));
|
|
}
|
|
activeRadio = Math.Min(activeRadio, positions.Count - 1);
|
|
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
|
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
|
|
qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
|
|
Logging.Logged += (_, qso) => _ = network?.SendAsync(qso, Settings.Station.Callsign);
|
|
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
|
|
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
|
|
Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign);
|
|
Check = new CheckWindowSources(positions[0], Calls, Bandmap);
|
|
Save(Settings with { ContestNumber = contestNumber });
|
|
ContestChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// One entry position per radio, and one when there is no radio at all.
|
|
private int PositionCount => Math.Max(1, Settings.Radios.Count(r => r.IsEnabled));
|
|
|
|
/// Starts, restarts or stops the link to the other stations of a
|
|
/// multi-operator entry, following what the settings now say.
|
|
public void ApplyNetworkSettings()
|
|
{
|
|
network?.Dispose();
|
|
network = null;
|
|
if (!Settings.NetworkEnabled)
|
|
{
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
return;
|
|
}
|
|
network = new StationNetwork(
|
|
Settings.NetworkPort,
|
|
Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName,
|
|
Settings.NetworkPeers);
|
|
network.UpdateArrived += (_, update) => TakeFromNetwork(update);
|
|
network.Start();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// What another station did to its log, applied to ours. The store is
|
|
/// written directly rather than through `Logging`, so the change is not
|
|
/// broadcast back out again. Points and multipliers are worked out here
|
|
/// from the rules instead of trusting what the sender put in the message.
|
|
private void TakeFromNetwork(ContactUpdate update)
|
|
{
|
|
if (Logging is null || store is null)
|
|
{
|
|
return;
|
|
}
|
|
int contestNumber = Logging.Instance.ContestNumber;
|
|
switch (update)
|
|
{
|
|
case ContactLogged logged when !Logging.Log.Qsos.Any(q => q.Id == logged.Qso.Id):
|
|
store.Add(logged.Qso with { ContestNumber = contestNumber, IsOriginal = false });
|
|
break;
|
|
case ContactReplaced replaced:
|
|
if (FindLocal(replaced.Qso.Id, replaced.OldCall, replaced.OldTimestampUtc) is not { } old)
|
|
{
|
|
return;
|
|
}
|
|
store.Update(replaced.Qso with
|
|
{
|
|
Id = old.Id,
|
|
ContestNumber = contestNumber,
|
|
IsOriginal = false,
|
|
});
|
|
break;
|
|
case ContactDeleted deleted:
|
|
if (FindLocal(deleted.Id, deleted.Call, deleted.TimestampUtc) is not { } gone)
|
|
{
|
|
return;
|
|
}
|
|
store.Delete(gone.Id);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
OpenContest(contestNumber);
|
|
}
|
|
|
|
/// N1MM keys a contact on its call and time, so a message from N1MM carries
|
|
/// no id we would recognise. Fall back to that pair when the id misses.
|
|
private Qso? FindLocal(string id, string call, DateTime timestampUtc) =>
|
|
Logging?.Log.Qsos.FirstOrDefault(q => id.Length > 0 && q.Id == id)
|
|
?? Logging?.Log.Qsos.FirstOrDefault(q =>
|
|
string.Equals(q.Call.Text, call, StringComparison.OrdinalIgnoreCase)
|
|
&& q.TimestampUtc == timestampUtc);
|
|
|
|
/// Starts, restarts or stops the keyer, following what the settings say.
|
|
/// A keyer that will not open is reported; the program keeps running
|
|
/// without one.
|
|
public void ApplyKeyerSettings()
|
|
{
|
|
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);
|
|
}
|
|
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;
|
|
}
|
|
activeRadio = (activeRadio + 1) % positions.Count;
|
|
_ = FollowActiveRadioAsync();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
ActiveRadioChanged?.Invoke(this, ActiveRadioNumber);
|
|
}
|
|
|
|
/// 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)
|
|
{
|
|
RadioPosition? position = positions.FirstOrDefault(p => p.RadioNumber == moved.Number);
|
|
if (position is null)
|
|
{
|
|
return;
|
|
}
|
|
position.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
|
|
}
|
|
|
|
private void DisposeRadios()
|
|
{
|
|
foreach (RigctldRadio open in radios)
|
|
{
|
|
open.Dispose();
|
|
}
|
|
radios.Clear();
|
|
}
|
|
|
|
public void ConnectCluster()
|
|
{
|
|
cluster?.Dispose();
|
|
cluster = new ClusterClient(
|
|
Settings.ClusterHost,
|
|
Settings.ClusterPort,
|
|
Settings.Station.Callsign,
|
|
Settings.ClusterCommands,
|
|
Settings.ClusterPassword);
|
|
cluster.SpotArrived += (_, spot) => Bandmap.Add(spot);
|
|
cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
|
cluster.Start();
|
|
}
|
|
|
|
public void DisconnectCluster()
|
|
{
|
|
cluster?.Dispose();
|
|
cluster = null;
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public void ReloadSupportFiles()
|
|
{
|
|
Countries = LoadCountryFile(Paths.CountryFile);
|
|
Calls = LoadCallDatabase(Paths.CallDatabaseFile);
|
|
History = LoadCallHistory(Settings.CallHistoryFile);
|
|
Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _);
|
|
if (Logging is not null)
|
|
{
|
|
OpenContest(Logging.Instance.ContestNumber);
|
|
}
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
DisposeRadios();
|
|
cluster?.Dispose();
|
|
network?.Dispose();
|
|
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;
|
|
}
|
|
}
|
|
}
|