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:
Erik
2026-08-27 10:56:35 +00:00
parent ffeeb2cdc1
commit 2834f63c8e
45 changed files with 2667 additions and 0 deletions

View 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;
}
}
}