Split. RadioState carries a transmit frequency, zero meaning the radio transmits where it listens, so split is not a second flag that can disagree with it. The contact stores it in N1MM's QSX column, the entry window shows 14008.00 followed by the transmit frequency, and the bandmap draws a red bar there. SetSplitAsync sets the frequency before turning split on, or a radio last split somewhere else transmits there. Every command now goes out with a + in front, asking rigctld for its extended answer: named fields ended by an RPRT line. The raw answer is bare values with no terminator, so the client had to know how many lines each command returns — there was a `command == "m"` special case for the one that returns two. Add a third such command and get the count wrong once, and every later answer is read against the wrong command for the rest of the session. RigctldReply parses the extended form and is tested on its own. A radio that cannot do split answers RPRT -11. That is an answer, not a broken connection, so the frequency and mode it did report still count. Two radios. Settings hold a list rather than one host and port, with the single radio an older settings file holds carried into it. Both radios are read and both show on the bandmap, the active one green and the other orange, but only the active one drives the entry window: the second radio moving must not drag the operator off the station being worked. Ctrl+Tab swaps, and a contact records which radio made it. This is not a full two-radio operating position — no second entry window, no alternating CQ, no audio switching. It is two radios read and logged correctly. Tested against a stand-in for rigctld over a real socket, and looked at under Xvfb with two fake radios, one of them split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
372 lines
13 KiB
C#
372 lines
13 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 int activeRadio;
|
|
private ClusterClient? cluster;
|
|
private StationNetwork? network;
|
|
private MessageSender? keyer;
|
|
|
|
public AppSession(UserPaths paths, Settings settings)
|
|
{
|
|
Paths = paths;
|
|
Settings = settings;
|
|
paths.CreateFolders();
|
|
Countries = LoadCountryFile(paths.CountryFile);
|
|
Calls = LoadCallDatabase(paths.CallDatabaseFile);
|
|
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; }
|
|
|
|
public Bandmap Bandmap { get; } = new();
|
|
|
|
public LoggingSession? Logging { get; private set; }
|
|
|
|
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 => Radio?.Number ?? 1;
|
|
|
|
public ClusterClient? Cluster => cluster;
|
|
|
|
public StationNetwork? Network => network;
|
|
|
|
public MessageSender? Keyer => keyer;
|
|
|
|
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 LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries)
|
|
{
|
|
RadioNumber = ActiveRadioNumber,
|
|
};
|
|
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(Logging, Calls, Bandmap);
|
|
Save(Settings with { ContestNumber = contestNumber });
|
|
ContestChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
activeRadio = Math.Min(activeRadio, Math.Max(0, radios.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 (radios.Count < 2)
|
|
{
|
|
return;
|
|
}
|
|
activeRadio = (activeRadio + 1) % radios.Count;
|
|
if (Logging is not null)
|
|
{
|
|
Logging.RadioNumber = ActiveRadioNumber;
|
|
if (Radio?.State is { } state)
|
|
{
|
|
Logging.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
|
|
}
|
|
}
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// 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)
|
|
{
|
|
if (moved.Number == ActiveRadioNumber)
|
|
{
|
|
Logging?.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
|
|
}
|
|
else
|
|
{
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
}
|
|
|
|
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);
|
|
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();
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|