Add the Avalonia application and the station network
The entry window types contacts and colours them as they are typed; the log, check, bandmap, score and packet windows read the same session. Contacts are shared with the other stations of a multi-operator entry in N1MM's contact message, so an N1MM station on the same network sees them too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
237
src/Nonemm.App/AppSession.cs
Normal file
237
src/Nonemm.App/AppSession.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Core.Country;
|
||||
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 RigctldRadio? radio;
|
||||
private ClusterClient? cluster;
|
||||
private StationNetwork? network;
|
||||
|
||||
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; }
|
||||
|
||||
public Radio? Radio => radio;
|
||||
|
||||
public ClusterClient? Cluster => cluster;
|
||||
|
||||
public StationNetwork? Network => network;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
Logging = new LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries);
|
||||
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);
|
||||
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.ContactArrived += (_, qso) => TakeFromNetwork(qso);
|
||||
network.Start();
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// A contact another station logged. It goes into the same log under the
|
||||
/// contest that is open, and is scored here rather than trusting the
|
||||
/// points the sender put in the message.
|
||||
private void TakeFromNetwork(Qso qso)
|
||||
{
|
||||
if (Logging is null || store is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Logging.Log.Qsos.Any(q => q.Id == qso.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Qso mine = qso with { ContestNumber = Logging.Instance.ContestNumber, IsOriginal = false };
|
||||
store.Add(mine);
|
||||
OpenContest(Logging.Instance.ContestNumber);
|
||||
}
|
||||
|
||||
public void ConnectRadio()
|
||||
{
|
||||
radio?.Dispose();
|
||||
radio = new RigctldRadio(Settings.RigctldHost, Settings.RigctldPort);
|
||||
radio.Moved += (_, state) => Logging?.Tune(state.Frequency, state.Mode);
|
||||
radio.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||
radio.Start();
|
||||
}
|
||||
|
||||
public void DisconnectRadio()
|
||||
{
|
||||
radio?.Dispose();
|
||||
radio = null;
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void ConnectCluster()
|
||||
{
|
||||
cluster?.Dispose();
|
||||
cluster = new ClusterClient(
|
||||
Settings.ClusterHost,
|
||||
Settings.ClusterPort,
|
||||
Settings.Station.Callsign,
|
||||
Settings.ClusterCommands);
|
||||
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()
|
||||
{
|
||||
radio?.Dispose();
|
||||
cluster?.Dispose();
|
||||
network?.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user