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:
@@ -9,6 +9,7 @@
|
||||
<Project Path="src/Nonemm.Network/Nonemm.Network.csproj" />
|
||||
<Project Path="src/Nonemm.Keying/Nonemm.Keying.csproj" />
|
||||
<Project Path="src/Nonemm.Session/Nonemm.Session.csproj" />
|
||||
<Project Path="src/Nonemm.App/Nonemm.App.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Nonemm.Core.Tests/Nonemm.Core.Tests.csproj" />
|
||||
|
||||
11
src/Nonemm.App/App.axaml
Normal file
11
src/Nonemm.App/App.axaml
Normal file
@@ -0,0 +1,11 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
25
src/Nonemm.App/App.axaml.cs
Normal file
25
src/Nonemm.App/App.axaml.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.App.Windows;
|
||||
|
||||
namespace Nonemm.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
UserPaths paths = UserPaths.Default();
|
||||
paths.CreateFolders();
|
||||
AppSession session = new(paths, Settings.Load(paths.SettingsFile));
|
||||
desktop.MainWindow = new EntryWindow(session);
|
||||
desktop.ShutdownRequested += (_, _) => session.Dispose();
|
||||
}
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
117
src/Nonemm.App/Configuration/Settings.cs
Normal file
117
src/Nonemm.App/Configuration/Settings.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// What the program remembers between runs.
|
||||
public sealed record Settings
|
||||
{
|
||||
public string DatabasePath { get; init; } = "";
|
||||
|
||||
public int ContestNumber { get; init; }
|
||||
|
||||
public StoredStation Station { get; init; } = new();
|
||||
|
||||
public string ClusterHost { get; init; } = "";
|
||||
|
||||
public int ClusterPort { get; init; } = 7373;
|
||||
|
||||
public IReadOnlyList<string> ClusterCommands { get; init; } = [];
|
||||
|
||||
public string RigctldHost { get; init; } = "127.0.0.1";
|
||||
|
||||
public int RigctldPort { get; init; } = 4532;
|
||||
|
||||
public bool RadioEnabled { get; init; }
|
||||
|
||||
public bool ClusterEnabled { get; init; }
|
||||
|
||||
public string NetworkStationName { get; init; } = "";
|
||||
|
||||
public int NetworkPort { get; init; } = 12060;
|
||||
|
||||
public bool NetworkEnabled { get; init; }
|
||||
|
||||
public IReadOnlyList<string> NetworkPeers { get; init; } = [];
|
||||
|
||||
public static Settings Load(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new Settings();
|
||||
}
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(File.ReadAllText(path), SettingsJson.Default.Settings)
|
||||
?? new Settings();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// a settings file that will not parse is replaced rather than
|
||||
// stopping the program before a contest
|
||||
return new Settings();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(string path) =>
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(this, SettingsJson.Default.Settings));
|
||||
}
|
||||
|
||||
/// The operator's station as it is stored, kept separate from `StationInfo` so
|
||||
/// the file format does not follow every change to the domain type.
|
||||
public sealed record StoredStation
|
||||
{
|
||||
public string Callsign { get; init; } = "";
|
||||
|
||||
public int CqZone { get; init; }
|
||||
|
||||
public int ItuZone { get; init; }
|
||||
|
||||
public string Continent { get; init; } = "";
|
||||
|
||||
public string CountryPrefix { get; init; } = "";
|
||||
|
||||
public string State { get; init; } = "";
|
||||
|
||||
public string Province { get; init; } = "";
|
||||
|
||||
public string ArrlSection { get; init; } = "";
|
||||
|
||||
public string GridSquare { get; init; } = "";
|
||||
|
||||
public string County { get; init; } = "";
|
||||
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Power { get; init; } = "";
|
||||
|
||||
public string Club { get; init; } = "";
|
||||
|
||||
public int Check { get; init; }
|
||||
|
||||
public string Precedence { get; init; } = "A";
|
||||
|
||||
public StationInfo ToStationInfo() => new()
|
||||
{
|
||||
Callsign = Callsign,
|
||||
CqZone = CqZone,
|
||||
ItuZone = ItuZone,
|
||||
Continent = Continent,
|
||||
CountryPrefix = CountryPrefix,
|
||||
State = State,
|
||||
Province = Province,
|
||||
ArrlSection = ArrlSection,
|
||||
GridSquare = GridSquare,
|
||||
County = County,
|
||||
Name = Name,
|
||||
Power = Power,
|
||||
Club = Club,
|
||||
Check = Check,
|
||||
Precedence = Precedence,
|
||||
};
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(Settings))]
|
||||
[JsonSourceGenerationOptions(WriteIndented = true)]
|
||||
internal sealed partial class SettingsJson : JsonSerializerContext;
|
||||
59
src/Nonemm.App/Configuration/SupportFileDownloader.cs
Normal file
59
src/Nonemm.App/Configuration/SupportFileDownloader.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Core.Country;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// Fetches the country file and the callsign database from where they are
|
||||
/// published, which is where N1MM fetches them from too. The file is checked
|
||||
/// that it parses as what it claims to be before it replaces the old one, so a
|
||||
/// site that answers with an apology page cannot cost an operator their
|
||||
/// multipliers mid-contest.
|
||||
public sealed class SupportFileDownloader
|
||||
{
|
||||
public const string CountryFileUrl = "https://www.country-files.com/cty/wl_cty.dat";
|
||||
public const string CallDatabaseUrl = "https://www.supercheckpartial.com/MASTER.SCP";
|
||||
|
||||
private readonly HttpClient http;
|
||||
|
||||
public SupportFileDownloader(HttpClient http) => this.http = http;
|
||||
|
||||
public Task<string> DownloadCountryFileAsync(string path, CancellationToken cancellation = default) =>
|
||||
DownloadAsync(CountryFileUrl, path, text => CountryFile.Parse(text).Entities.Count, "entities", cancellation);
|
||||
|
||||
public Task<string> DownloadCallDatabaseAsync(string path, CancellationToken cancellation = default) =>
|
||||
DownloadAsync(CallDatabaseUrl, path, text => CallDatabase.Parse(text).Count, "callsigns", cancellation);
|
||||
|
||||
private async Task<string> DownloadAsync(
|
||||
string url,
|
||||
string path,
|
||||
Func<string, int> check,
|
||||
string what,
|
||||
CancellationToken cancellation)
|
||||
{
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = await http.GetStringAsync(url, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException e)
|
||||
{
|
||||
throw new InvalidOperationException($"could not fetch {url}: {e.Message}", e);
|
||||
}
|
||||
|
||||
int count;
|
||||
try
|
||||
{
|
||||
count = check(text);
|
||||
}
|
||||
catch (FormatException e)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{url} did not answer with a usable file, so the old one is untouched: {e.Message}", e);
|
||||
}
|
||||
|
||||
string temporary = path + ".new";
|
||||
await File.WriteAllTextAsync(temporary, text, cancellation).ConfigureAwait(false);
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
return $"{count} {what}";
|
||||
}
|
||||
}
|
||||
47
src/Nonemm.App/Configuration/UserPaths.cs
Normal file
47
src/Nonemm.App/Configuration/UserPaths.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// Where the operator's files live. Windows keeps them under Documents the way
|
||||
/// N1MM does; everywhere else follows the XDG configuration directory.
|
||||
public sealed class UserPaths
|
||||
{
|
||||
public UserPaths(string root)
|
||||
{
|
||||
Root = root;
|
||||
Databases = Path.Combine(root, "Databases");
|
||||
UserDefinedContests = Path.Combine(root, "UserDefinedContests");
|
||||
SupportFiles = Path.Combine(root, "SupportFiles");
|
||||
}
|
||||
|
||||
public static UserPaths Default() => new(DefaultRoot());
|
||||
|
||||
public string Root { get; }
|
||||
|
||||
public string Databases { get; }
|
||||
|
||||
public string UserDefinedContests { get; }
|
||||
|
||||
public string SupportFiles { get; }
|
||||
|
||||
public string SettingsFile => Path.Combine(Root, "settings.json");
|
||||
|
||||
public string CountryFile => Path.Combine(SupportFiles, "wl_cty.dat");
|
||||
|
||||
public string CallDatabaseFile => Path.Combine(SupportFiles, "MASTER.SCP");
|
||||
|
||||
public void CreateFolders()
|
||||
{
|
||||
Directory.CreateDirectory(Root);
|
||||
Directory.CreateDirectory(Databases);
|
||||
Directory.CreateDirectory(UserDefinedContests);
|
||||
Directory.CreateDirectory(SupportFiles);
|
||||
}
|
||||
|
||||
private static string DefaultRoot() =>
|
||||
OperatingSystem.IsWindows()
|
||||
? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
"Nonemm")
|
||||
: Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"nonemm");
|
||||
}
|
||||
21
src/Nonemm.App/Dialogs/ClusterDialog.axaml
Normal file
21
src/Nonemm.App/Dialogs/ClusterDialog.axaml
Normal file
@@ -0,0 +1,21 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.ClusterDialog"
|
||||
Title="DX cluster" Width="440" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner" CanResize="False">
|
||||
<StackPanel Margin="14" Spacing="6">
|
||||
<Grid ColumnDefinitions="*,8,110" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Text="Node" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
|
||||
<TextBox Name="HostBox" Grid.Row="1" Watermark="dxc.example.net" />
|
||||
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
|
||||
<TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
|
||||
</Grid>
|
||||
<TextBlock Text="Commands sent after login, one per line" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="CommandsBox" AcceptsReturn="True" Height="90" />
|
||||
<CheckBox Name="EnabledBox" Content="Connect" Margin="0,6,0,0" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
37
src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs
Normal file
37
src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Configuration;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
/// The cluster node to connect to. Filters are the node's business, so whatever
|
||||
/// the operator puts in the command box is sent after login and left alone.
|
||||
public sealed partial class ClusterDialog : Window
|
||||
{
|
||||
private readonly Settings settings;
|
||||
|
||||
public ClusterDialog(Settings settings)
|
||||
{
|
||||
this.settings = settings;
|
||||
InitializeComponent();
|
||||
HostBox.Text = settings.ClusterHost;
|
||||
PortBox.Text = settings.ClusterPort.ToString();
|
||||
CommandsBox.Text = string.Join("\n", settings.ClusterCommands);
|
||||
EnabledBox.IsChecked = settings.ClusterEnabled;
|
||||
}
|
||||
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
|
||||
{
|
||||
ClusterHost = (HostBox.Text ?? "").Trim(),
|
||||
ClusterPort = int.TryParse(PortBox.Text, out int port) ? port : 7373,
|
||||
ClusterCommands = (CommandsBox.Text ?? "")
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 0)
|
||||
.ToList(),
|
||||
ClusterEnabled = EnabledBox.IsChecked == true,
|
||||
});
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||
}
|
||||
14
src/Nonemm.App/Dialogs/ContestPickerDialog.axaml
Normal file
14
src/Nonemm.App/Dialogs/ContestPickerDialog.axaml
Normal file
@@ -0,0 +1,14 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.ContestPickerDialog"
|
||||
Title="Open contest" Width="440" Height="320"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<DockPanel Margin="14">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right"
|
||||
Spacing="6" Margin="0,10,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Open" Click="OnOpen" IsDefault="True" />
|
||||
</StackPanel>
|
||||
<ListBox Name="ContestList" />
|
||||
</DockPanel>
|
||||
</Window>
|
||||
31
src/Nonemm.App/Dialogs/ContestPickerDialog.axaml.cs
Normal file
31
src/Nonemm.App/Dialogs/ContestPickerDialog.axaml.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.Storage;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
/// Picks one of the contests already in the log database.
|
||||
public sealed partial class ContestPickerDialog : Window
|
||||
{
|
||||
private readonly IReadOnlyList<ContestInstance> contests;
|
||||
|
||||
public ContestPickerDialog(IReadOnlyList<ContestInstance> contests)
|
||||
{
|
||||
this.contests = contests;
|
||||
InitializeComponent();
|
||||
ContestList.ItemsSource = contests
|
||||
.Select(c => $"{c.ContestNumber} {c.ContestName} {c.StartDate:yyyy-MM-dd} {c.OperatorCategory}")
|
||||
.ToList();
|
||||
ContestList.SelectedIndex = contests.Count - 1;
|
||||
ContestList.DoubleTapped += (_, _) => OnOpen(this, new RoutedEventArgs());
|
||||
}
|
||||
|
||||
|
||||
private void OnOpen(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
int at = ContestList.SelectedIndex;
|
||||
Close(at >= 0 ? contests[at].ContestNumber : (int?)null);
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||
}
|
||||
42
src/Nonemm.App/Dialogs/ContestSetupDialog.axaml
Normal file
42
src/Nonemm.App/Dialogs/ContestSetupDialog.axaml
Normal file
@@ -0,0 +1,42 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.ContestSetupDialog"
|
||||
Title="New contest" Width="460" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner" CanResize="False">
|
||||
<StackPanel Margin="14" Spacing="8">
|
||||
<TextBlock Text="Contest" FontSize="11" Opacity="0.7" />
|
||||
<ComboBox Name="ContestBox" HorizontalAlignment="Stretch" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
|
||||
<TextBlock Text="Mode" FontSize="11" Opacity="0.7" />
|
||||
<ComboBox Name="ModeBox" Grid.Row="1" HorizontalAlignment="Stretch" />
|
||||
<TextBlock Grid.Column="2" Text="Operator category" FontSize="11" Opacity="0.7" />
|
||||
<ComboBox Name="OperatorBox" Grid.Row="1" Grid.Column="2" HorizontalAlignment="Stretch" />
|
||||
|
||||
<TextBlock Grid.Row="2" Text="Band" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
||||
<ComboBox Name="BandBox" Grid.Row="3" HorizontalAlignment="Stretch" />
|
||||
<TextBlock Grid.Row="2" Grid.Column="2" Text="Power" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
||||
<ComboBox Name="PowerBox" Grid.Row="3" Grid.Column="2" HorizontalAlignment="Stretch" />
|
||||
|
||||
<TextBlock Grid.Row="4" Text="Assisted" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
||||
<ComboBox Name="AssistedBox" Grid.Row="5" HorizontalAlignment="Stretch" />
|
||||
<TextBlock Grid.Row="4" Grid.Column="2" Text="Transmitters" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
||||
<ComboBox Name="TransmitterBox" Grid.Row="5" Grid.Column="2" HorizontalAlignment="Stretch" />
|
||||
|
||||
<TextBlock Grid.Row="6" Text="Overlay" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
||||
<TextBox Name="OverlayBox" Grid.Row="7" />
|
||||
<TextBlock Grid.Row="6" Grid.Column="2" Text="Operators" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
||||
<TextBox Name="OperatorsBox" Grid.Row="7" Grid.Column="2" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Sent exchange" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
||||
<TextBox Name="ExchangeBox" />
|
||||
|
||||
<TextBlock Name="HintText" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Start" Click="OnStart" IsDefault="True" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
77
src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs
Normal file
77
src/Nonemm.App/Dialogs/ContestSetupDialog.axaml.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Storage;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
/// Picks the contest and the entry categories the sponsor asks for in the
|
||||
/// Cabrillo header.
|
||||
public sealed partial class ContestSetupDialog : Window
|
||||
{
|
||||
private readonly AppSession session;
|
||||
|
||||
public ContestSetupDialog(AppSession session)
|
||||
{
|
||||
this.session = session;
|
||||
InitializeComponent();
|
||||
ContestBox.ItemsSource = session.Registry.Choices.Select(c => c.DisplayName).ToList();
|
||||
ContestBox.SelectedIndex = 0;
|
||||
ContestBox.SelectionChanged += (_, _) => ShowChosen();
|
||||
ModeBox.ItemsSource = new[] { "CW", "SSB", "RTTY", "MIXED" };
|
||||
ModeBox.SelectedIndex = 0;
|
||||
OperatorBox.ItemsSource = new[] { "SINGLE-OP", "MULTI-OP", "CHECKLIST" };
|
||||
OperatorBox.SelectedIndex = 0;
|
||||
BandBox.ItemsSource = new[] { "ALL", "160M", "80M", "40M", "20M", "15M", "10M" };
|
||||
BandBox.SelectedIndex = 0;
|
||||
PowerBox.ItemsSource = new[] { "HIGH", "LOW", "QRP" };
|
||||
PowerBox.SelectedIndex = 0;
|
||||
AssistedBox.ItemsSource = new[] { "NON-ASSISTED", "ASSISTED" };
|
||||
AssistedBox.SelectedIndex = 0;
|
||||
TransmitterBox.ItemsSource = new[] { "ONE", "TWO", "LIMITED", "UNLIMITED", "SWL" };
|
||||
TransmitterBox.SelectedIndex = 0;
|
||||
OperatorsBox.Text = session.Settings.Station.Callsign;
|
||||
ShowChosen();
|
||||
}
|
||||
|
||||
|
||||
private ContestChoice Chosen =>
|
||||
session.Registry.Choices[Math.Max(0, ContestBox.SelectedIndex)];
|
||||
|
||||
private void ShowChosen()
|
||||
{
|
||||
Contest contest = Chosen.Create(ModeOf());
|
||||
ExchangeBox.Text = contest.SentExchangeFor(session.Settings.Station.ToStationInfo());
|
||||
HintText.Text = $"Cabrillo name {contest.CabrilloName} · exchange " +
|
||||
string.Join(", ", contest.ExchangeFieldsFor(session.Settings.Station.ToStationInfo())
|
||||
.Select(f => f.Label));
|
||||
}
|
||||
|
||||
private ModeCategory ModeOf() => (ModeBox.SelectedItem as string) switch
|
||||
{
|
||||
"SSB" => ModeCategory.Phone,
|
||||
"RTTY" => ModeCategory.Digital,
|
||||
_ => ModeCategory.Cw,
|
||||
};
|
||||
|
||||
private void OnStart(object? sender, RoutedEventArgs e) => Close(new ContestInstance
|
||||
{
|
||||
ContestNumber = 0,
|
||||
ContestName = Chosen.Name,
|
||||
StartDate = DateTime.UtcNow,
|
||||
SentExchange = ExchangeBox.Text ?? "",
|
||||
OperatorCategory = Text(OperatorBox),
|
||||
BandCategory = Text(BandBox),
|
||||
PowerCategory = Text(PowerBox),
|
||||
ModeCategory = Text(ModeBox),
|
||||
AssistedCategory = Text(AssistedBox),
|
||||
TransmitterCategory = Text(TransmitterBox),
|
||||
OverlayCategory = OverlayBox.Text ?? "",
|
||||
Operators = OperatorsBox.Text ?? "",
|
||||
});
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||
|
||||
private static string Text(ComboBox box) => box.SelectedItem as string ?? "";
|
||||
}
|
||||
23
src/Nonemm.App/Dialogs/NetworkDialog.axaml
Normal file
23
src/Nonemm.App/Dialogs/NetworkDialog.axaml
Normal file
@@ -0,0 +1,23 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.NetworkDialog"
|
||||
Title="Networked stations" Width="440" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner" CanResize="False">
|
||||
<StackPanel Margin="14" Spacing="6">
|
||||
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||
Text="Each contact is sent to the other stations of the entry as it is logged, in N1MM's contact message, so an N1MM station on the same network sees them too. With no addresses listed the contacts are broadcast." />
|
||||
<Grid ColumnDefinitions="*,8,110" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Text="This station's name" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="NameBox" Grid.Row="1" />
|
||||
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
|
||||
</Grid>
|
||||
<TextBlock Text="Other stations, one address per line" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="PeersBox" AcceptsReturn="True" Height="90" Watermark="192.168.1.11" />
|
||||
<CheckBox Name="EnabledBox" Content="Share contacts with the other stations" Margin="0,6,0,0" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
37
src/Nonemm.App/Dialogs/NetworkDialog.axaml.cs
Normal file
37
src/Nonemm.App/Dialogs/NetworkDialog.axaml.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Configuration;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
public sealed partial class NetworkDialog : Window
|
||||
{
|
||||
private readonly Settings settings;
|
||||
|
||||
public NetworkDialog(Settings settings)
|
||||
{
|
||||
this.settings = settings;
|
||||
InitializeComponent();
|
||||
NameBox.Text = settings.NetworkStationName.Length > 0
|
||||
? settings.NetworkStationName
|
||||
: Environment.MachineName;
|
||||
PortBox.Text = settings.NetworkPort.ToString();
|
||||
PeersBox.Text = string.Join("\n", settings.NetworkPeers);
|
||||
EnabledBox.IsChecked = settings.NetworkEnabled;
|
||||
}
|
||||
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
|
||||
{
|
||||
NetworkStationName = (NameBox.Text ?? "").Trim(),
|
||||
NetworkPort = int.TryParse(PortBox.Text, out int port) ? port : 12060,
|
||||
NetworkPeers = (PeersBox.Text ?? "")
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 0)
|
||||
.ToList(),
|
||||
NetworkEnabled = EnabledBox.IsChecked == true,
|
||||
});
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||
}
|
||||
21
src/Nonemm.App/Dialogs/RadioDialog.axaml
Normal file
21
src/Nonemm.App/Dialogs/RadioDialog.axaml
Normal file
@@ -0,0 +1,21 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.RadioDialog"
|
||||
Title="Radio" Width="420" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner" CanResize="False">
|
||||
<StackPanel Margin="14" Spacing="6">
|
||||
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||
Text="The logger reads and tunes the radio through hamlib's rigctld, which is started separately for whichever radio is on the desk, for example: rigctld -m 2028 -r /dev/ttyUSB0" />
|
||||
<Grid ColumnDefinitions="*,8,110" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Text="Host" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="HostBox" Grid.Row="1" />
|
||||
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
|
||||
</Grid>
|
||||
<CheckBox Name="EnabledBox" Content="Follow the radio" Margin="0,6,0,0" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
29
src/Nonemm.App/Dialogs/RadioDialog.axaml.cs
Normal file
29
src/Nonemm.App/Dialogs/RadioDialog.axaml.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Configuration;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
public sealed partial class RadioDialog : Window
|
||||
{
|
||||
private readonly Settings settings;
|
||||
|
||||
public RadioDialog(Settings settings)
|
||||
{
|
||||
this.settings = settings;
|
||||
InitializeComponent();
|
||||
HostBox.Text = settings.RigctldHost;
|
||||
PortBox.Text = settings.RigctldPort.ToString();
|
||||
EnabledBox.IsChecked = settings.RadioEnabled;
|
||||
}
|
||||
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
|
||||
{
|
||||
RigctldHost = (HostBox.Text ?? "127.0.0.1").Trim(),
|
||||
RigctldPort = int.TryParse(PortBox.Text, out int port) ? port : 4532,
|
||||
RadioEnabled = EnabledBox.IsChecked == true,
|
||||
});
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||
}
|
||||
56
src/Nonemm.App/Dialogs/StationDialog.axaml
Normal file
56
src/Nonemm.App/Dialogs/StationDialog.axaml
Normal file
@@ -0,0 +1,56 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.StationDialog"
|
||||
Title="Station" Width="440" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner" CanResize="False">
|
||||
<Window.Styles>
|
||||
<Style Selector="TextBlock.field">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
<Setter Property="Margin" Value="0,6,0,1" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
<StackPanel Margin="14" Spacing="0">
|
||||
<Grid ColumnDefinitions="*,8,*,8,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
|
||||
<TextBlock Classes="field" Text="Callsign" />
|
||||
<TextBox Name="CallsignBox" Grid.Row="1" />
|
||||
<TextBlock Classes="field" Grid.Column="2" Text="CQ zone" />
|
||||
<TextBox Name="CqZoneBox" Grid.Row="1" Grid.Column="2" />
|
||||
<TextBlock Classes="field" Grid.Column="4" Text="ITU zone" />
|
||||
<TextBox Name="ItuZoneBox" Grid.Row="1" Grid.Column="4" />
|
||||
|
||||
<TextBlock Classes="field" Grid.Row="2" Text="Continent" />
|
||||
<TextBox Name="ContinentBox" Grid.Row="3" />
|
||||
<TextBlock Classes="field" Grid.Row="2" Grid.Column="2" Text="Country prefix" />
|
||||
<TextBox Name="CountryBox" Grid.Row="3" Grid.Column="2" />
|
||||
<TextBlock Classes="field" Grid.Row="2" Grid.Column="4" Text="Grid square" />
|
||||
<TextBox Name="GridBox" Grid.Row="3" Grid.Column="4" />
|
||||
|
||||
<TextBlock Classes="field" Grid.Row="4" Text="State" />
|
||||
<TextBox Name="StateBox" Grid.Row="5" />
|
||||
<TextBlock Classes="field" Grid.Row="4" Grid.Column="2" Text="Province" />
|
||||
<TextBox Name="ProvinceBox" Grid.Row="5" Grid.Column="2" />
|
||||
<TextBlock Classes="field" Grid.Row="4" Grid.Column="4" Text="ARRL section" />
|
||||
<TextBox Name="SectionBox" Grid.Row="5" Grid.Column="4" />
|
||||
|
||||
<TextBlock Classes="field" Grid.Row="6" Text="Name" />
|
||||
<TextBox Name="NameBox" Grid.Row="7" />
|
||||
<TextBlock Classes="field" Grid.Row="6" Grid.Column="2" Text="Power" />
|
||||
<TextBox Name="PowerBox" Grid.Row="7" Grid.Column="2" />
|
||||
<TextBlock Classes="field" Grid.Row="6" Grid.Column="4" Text="Club" />
|
||||
<TextBox Name="ClubBox" Grid.Row="7" Grid.Column="4" />
|
||||
|
||||
<TextBlock Classes="field" Grid.Row="8" Text="Check (year first licensed)" />
|
||||
<TextBox Name="CheckBox" Grid.Row="9" />
|
||||
<TextBlock Classes="field" Grid.Row="8" Grid.Column="2" Text="Precedence" />
|
||||
<TextBox Name="PrecedenceBox" Grid.Row="9" Grid.Column="2" />
|
||||
<TextBlock Classes="field" Grid.Row="8" Grid.Column="4" Text="County" />
|
||||
<TextBox Name="CountyBox" Grid.Row="9" Grid.Column="4" />
|
||||
</Grid>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,14,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
54
src/Nonemm.App/Dialogs/StationDialog.axaml.cs
Normal file
54
src/Nonemm.App/Dialogs/StationDialog.axaml.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Configuration;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
/// The operator's own station: what goes in the sent exchange and what contest
|
||||
/// rules compare a worked station against.
|
||||
public sealed partial class StationDialog : Window
|
||||
{
|
||||
public StationDialog(StoredStation station)
|
||||
{
|
||||
InitializeComponent();
|
||||
CallsignBox.Text = station.Callsign;
|
||||
CqZoneBox.Text = station.CqZone.ToString();
|
||||
ItuZoneBox.Text = station.ItuZone.ToString();
|
||||
ContinentBox.Text = station.Continent;
|
||||
CountryBox.Text = station.CountryPrefix;
|
||||
GridBox.Text = station.GridSquare;
|
||||
StateBox.Text = station.State;
|
||||
ProvinceBox.Text = station.Province;
|
||||
SectionBox.Text = station.ArrlSection;
|
||||
NameBox.Text = station.Name;
|
||||
PowerBox.Text = station.Power;
|
||||
ClubBox.Text = station.Club;
|
||||
CheckBox.Text = station.Check.ToString();
|
||||
PrecedenceBox.Text = station.Precedence;
|
||||
CountyBox.Text = station.County;
|
||||
}
|
||||
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e) => Close(new StoredStation
|
||||
{
|
||||
Callsign = (CallsignBox.Text ?? "").Trim().ToUpperInvariant(),
|
||||
CqZone = Number(CqZoneBox),
|
||||
ItuZone = Number(ItuZoneBox),
|
||||
Continent = (ContinentBox.Text ?? "").Trim().ToUpperInvariant(),
|
||||
CountryPrefix = (CountryBox.Text ?? "").Trim().ToUpperInvariant(),
|
||||
GridSquare = (GridBox.Text ?? "").Trim(),
|
||||
State = (StateBox.Text ?? "").Trim().ToUpperInvariant(),
|
||||
Province = (ProvinceBox.Text ?? "").Trim().ToUpperInvariant(),
|
||||
ArrlSection = (SectionBox.Text ?? "").Trim().ToUpperInvariant(),
|
||||
Name = (NameBox.Text ?? "").Trim(),
|
||||
Power = (PowerBox.Text ?? "").Trim(),
|
||||
Club = (ClubBox.Text ?? "").Trim(),
|
||||
Check = Number(CheckBox),
|
||||
Precedence = (PrecedenceBox.Text ?? "").Trim().ToUpperInvariant(),
|
||||
County = (CountyBox.Text ?? "").Trim().ToUpperInvariant(),
|
||||
});
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||
|
||||
private static int Number(TextBox box) => int.TryParse(box.Text, out int value) ? value : 0;
|
||||
}
|
||||
22
src/Nonemm.App/Messages.cs
Normal file
22
src/Nonemm.App/Messages.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace Nonemm.App;
|
||||
|
||||
/// The function key messages. The text uses N1MM's macro names so a message
|
||||
/// file written for either program means the same thing.
|
||||
public static class Messages
|
||||
{
|
||||
public static readonly IReadOnlyList<(string Key, string Label, string Text)> Defaults =
|
||||
[
|
||||
("F1", "CQ", "CQ TEST {MYCALL} {MYCALL}"),
|
||||
("F2", "Exch", "{SENTRST} {EXCH}"),
|
||||
("F3", "TU", "TU {MYCALL}"),
|
||||
("F4", "MyCall", "{MYCALL}"),
|
||||
("F5", "HisCall", "{CALL}"),
|
||||
("F6", "Repeat", "{EXCH} {EXCH}"),
|
||||
("F7", "?", "?"),
|
||||
("F8", "Agn", "AGN"),
|
||||
("F9", "Nr?", "NR?"),
|
||||
("F10", "Call?", "CALL?"),
|
||||
("F11", "Spot", ""),
|
||||
("F12", "Wipe", ""),
|
||||
];
|
||||
}
|
||||
31
src/Nonemm.App/Nonemm.App.csproj
Normal file
31
src/Nonemm.App/Nonemm.App.csproj
Normal file
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.1" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Contests\Nonemm.Contests.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Formats\Nonemm.Formats.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Storage\Nonemm.Storage.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Rig\Nonemm.Rig.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Spotting\Nonemm.Spotting.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Session\Nonemm.Session.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Network\Nonemm.Network.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
24
src/Nonemm.App/Program.cs
Normal file
24
src/Nonemm.App/Program.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Avalonia;
|
||||
using System;
|
||||
|
||||
namespace Nonemm.App;
|
||||
|
||||
class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
#if DEBUG
|
||||
.WithDeveloperTools()
|
||||
#endif
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
37
src/Nonemm.App/Verdicts.cs
Normal file
37
src/Nonemm.App/Verdicts.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Avalonia.Media;
|
||||
using Nonemm.Contests;
|
||||
|
||||
namespace Nonemm.App;
|
||||
|
||||
/// One place decides what colour a station is, so the frame round the callsign
|
||||
/// box, a row in the check window and a spot on the bandmap cannot disagree.
|
||||
public static class Verdicts
|
||||
{
|
||||
public static readonly IBrush Dupe = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B));
|
||||
public static readonly IBrush NewMultiplier = new SolidColorBrush(Color.FromRgb(0x27, 0xAE, 0x60));
|
||||
public static readonly IBrush Worth = new SolidColorBrush(Color.FromRgb(0x29, 0x80, 0xB9));
|
||||
public static readonly IBrush Nothing = new SolidColorBrush(Color.FromRgb(0x7F, 0x8C, 0x8D));
|
||||
|
||||
public static IBrush Colour(Verdict? verdict) =>
|
||||
verdict is null ? Nothing
|
||||
: verdict.IsDupe ? Dupe
|
||||
: verdict.IsNewMultiplier ? NewMultiplier
|
||||
: verdict.Points > 0 ? Worth
|
||||
: Nothing;
|
||||
|
||||
public static string Describe(Verdict? verdict)
|
||||
{
|
||||
if (verdict is null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
if (verdict.IsDupe)
|
||||
{
|
||||
return "dupe";
|
||||
}
|
||||
string points = $"{verdict.Points} {(verdict.Points == 1 ? "point" : "points")}";
|
||||
return verdict.IsNewMultiplier
|
||||
? $"{points} · new {string.Join(", ", verdict.NewMultipliers.Select(m => m.Value))}"
|
||||
: points;
|
||||
}
|
||||
}
|
||||
13
src/Nonemm.App/Windows/BandmapWindow.axaml
Normal file
13
src/Nonemm.App/Windows/BandmapWindow.axaml
Normal file
@@ -0,0 +1,13 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
x:Class="Nonemm.App.Windows.BandmapWindow"
|
||||
Title="Bandmap" Width="320" Height="520">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="6,6,6,2" Spacing="6">
|
||||
<TextBlock Name="BandText" FontSize="12" VerticalAlignment="Center" />
|
||||
<CheckBox Name="ThisBandOnly" Content="this band" IsChecked="True" FontSize="11" />
|
||||
</StackPanel>
|
||||
<ListBox Name="Spots" FontFamily="monospace" FontSize="13" />
|
||||
</DockPanel>
|
||||
</local:RefreshableWindow>
|
||||
92
src/Nonemm.App/Windows/BandmapWindow.axaml.cs
Normal file
92
src/Nonemm.App/Windows/BandmapWindow.axaml.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The band as a list of stations in frequency order: cluster spots and every
|
||||
/// station this log has worked. Clicking one puts the radio on it with the
|
||||
/// callsign already in the entry window.
|
||||
public sealed partial class BandmapWindow : RefreshableWindow
|
||||
{
|
||||
private readonly AppSession session;
|
||||
private readonly Action<Frequency, string> tune;
|
||||
|
||||
public BandmapWindow(AppSession session, Action<Frequency, string> tune)
|
||||
{
|
||||
this.session = session;
|
||||
this.tune = tune;
|
||||
InitializeComponent();
|
||||
Spots.SelectionChanged += (_, _) => OnPicked();
|
||||
ThisBandOnly.IsCheckedChanged += (_, _) => Refresh();
|
||||
session.Bandmap.Changed += (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(Refresh);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
|
||||
public override void Refresh()
|
||||
{
|
||||
session.Bandmap.DropOlderThan(DateTime.UtcNow);
|
||||
Band? band = session.Logging is null ? null : Bands.ForFrequency(session.Logging.Frequency);
|
||||
BandText.Text = band?.Name ?? "no band";
|
||||
|
||||
IReadOnlyList<Spot> spots = ThisBandOnly.IsChecked == true && band is not null
|
||||
? session.Bandmap.On(band)
|
||||
: session.Bandmap.All();
|
||||
Spots.ItemsSource = spots.Select(BuildRow).ToList();
|
||||
}
|
||||
|
||||
private Control BuildRow(Spot spot)
|
||||
{
|
||||
StackPanel row = new() { Orientation = Orientation.Horizontal, Tag = spot };
|
||||
row.Children.Add(new TextBlock
|
||||
{
|
||||
Text = spot.Frequency.Kilohertz.ToString("0.0").PadLeft(8),
|
||||
FontFamily = new FontFamily("monospace"),
|
||||
Opacity = 0.75,
|
||||
Margin = new Avalonia.Thickness(0, 0, 8, 0),
|
||||
});
|
||||
row.Children.Add(new TextBlock
|
||||
{
|
||||
Text = spot.Call.Text,
|
||||
FontFamily = new FontFamily("monospace"),
|
||||
Foreground = Verdicts.Colour(VerdictFor(spot)),
|
||||
});
|
||||
row.Children.Add(new TextBlock
|
||||
{
|
||||
Text = spot.Source == SpotSource.Log ? " worked" : $" {spot.Spotter}",
|
||||
FontSize = 11,
|
||||
Opacity = 0.6,
|
||||
Margin = new Avalonia.Thickness(6, 2, 0, 0),
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
private Verdict? VerdictFor(Spot spot)
|
||||
{
|
||||
if (session.Logging is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return session.Logging.Log.Judge(new Qso
|
||||
{
|
||||
Id = "",
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
Call = spot.Call,
|
||||
Frequency = spot.Frequency,
|
||||
Mode = session.Logging.Mode,
|
||||
ContestName = session.Logging.Contest.Name,
|
||||
});
|
||||
}
|
||||
|
||||
private void OnPicked()
|
||||
{
|
||||
if (Spots.SelectedItem is Control { Tag: Spot spot })
|
||||
{
|
||||
tune(spot.Frequency, spot.Call.Text);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/Nonemm.App/Windows/CheckWindow.axaml
Normal file
7
src/Nonemm.App/Windows/CheckWindow.axaml
Normal file
@@ -0,0 +1,7 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
x:Class="Nonemm.App.Windows.CheckWindow"
|
||||
Title="Check" Width="520" Height="300">
|
||||
<Grid Name="Columns" ColumnDefinitions="*,*,*" Margin="6" />
|
||||
</local:RefreshableWindow>
|
||||
76
src/Nonemm.App/Windows/CheckWindow.axaml.cs
Normal file
76
src/Nonemm.App/Windows/CheckWindow.axaml.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// What the callsign being typed could be, a column per source. The columns
|
||||
/// stay apart because they answer different questions, and merged into one list
|
||||
/// they would all read as equally reliable.
|
||||
public sealed partial class CheckWindow : RefreshableWindow
|
||||
{
|
||||
private readonly AppSession session;
|
||||
private readonly Func<string> typed;
|
||||
|
||||
public CheckWindow(AppSession session, Func<string> typed)
|
||||
{
|
||||
this.session = session;
|
||||
this.typed = typed;
|
||||
InitializeComponent();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
|
||||
public override void Refresh()
|
||||
{
|
||||
Columns.Children.Clear();
|
||||
if (session.Check is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
int at = 0;
|
||||
foreach (CheckColumn column in session.Check.Columns(typed()))
|
||||
{
|
||||
Control panel = BuildColumn(column);
|
||||
Grid.SetColumn(panel, at++);
|
||||
Columns.Children.Add(panel);
|
||||
}
|
||||
}
|
||||
|
||||
private static Control BuildColumn(CheckColumn column)
|
||||
{
|
||||
StackPanel panel = new() { Margin = new Avalonia.Thickness(0, 0, 8, 0) };
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"{Heading(column.Source)} {Count(column)}",
|
||||
FontSize = 11,
|
||||
Opacity = 0.7,
|
||||
Margin = new Avalonia.Thickness(0, 0, 0, 4),
|
||||
});
|
||||
foreach (CheckCandidate candidate in column.Candidates)
|
||||
{
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = candidate.Call,
|
||||
FontFamily = new FontFamily("monospace"),
|
||||
FontSize = 14,
|
||||
Foreground = Verdicts.Colour(candidate.Verdict),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
});
|
||||
}
|
||||
return new ScrollViewer { Content = panel };
|
||||
}
|
||||
|
||||
/// Before anything is typed the heading says how many calls the source
|
||||
/// holds; once it is offering candidates it says how many.
|
||||
private static string Count(CheckColumn column) =>
|
||||
column.Candidates.Count > 0 ? column.Candidates.Count.ToString() : $"({column.Held})";
|
||||
|
||||
private static string Heading(CheckSource source) => source switch
|
||||
{
|
||||
CheckSource.Log => "Log",
|
||||
CheckSource.Database => "Master",
|
||||
_ => "Bandmap",
|
||||
};
|
||||
}
|
||||
328
src/Nonemm.App/Windows/EntryWindow.Menu.cs
Normal file
328
src/Nonemm.App/Windows/EntryWindow.Menu.cs
Normal file
@@ -0,0 +1,328 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.App.Dialogs;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Formats.Adif;
|
||||
using Nonemm.Formats.Cabrillo;
|
||||
using Nonemm.Storage;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The entry window's menu. Each item does one thing and says what happened in
|
||||
/// the status line.
|
||||
public sealed partial class EntryWindow
|
||||
{
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
private async void OnNewDatabase(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "New log database",
|
||||
SuggestedFileName = "ham.s3db",
|
||||
DefaultExtension = "s3db",
|
||||
SuggestedStartLocation = await Folder(session.Paths.Databases),
|
||||
});
|
||||
OpenDatabaseAt(file);
|
||||
}
|
||||
|
||||
private async void OnOpenDatabase(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Open log database",
|
||||
AllowMultiple = false,
|
||||
SuggestedStartLocation = await Folder(session.Paths.Databases),
|
||||
});
|
||||
OpenDatabaseAt(files.FirstOrDefault());
|
||||
}
|
||||
|
||||
private void OpenDatabaseAt(IStorageFile? file)
|
||||
{
|
||||
if (file?.TryGetLocalPath() is not { } path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
session.OpenDatabase(path);
|
||||
Status($"log database {Path.GetFileName(path)}");
|
||||
}
|
||||
catch (Exception error) when (error is IOException or InvalidOperationException)
|
||||
{
|
||||
Status($"could not open {path}: {error.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnNewContest(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!HasDatabase())
|
||||
{
|
||||
return;
|
||||
}
|
||||
ContestSetupDialog dialog = new(session);
|
||||
ContestInstance? chosen = await dialog.ShowDialog<ContestInstance?>(this);
|
||||
if (chosen is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.StartContest(chosen);
|
||||
Status($"{chosen.ContestName} started");
|
||||
}
|
||||
|
||||
private async void OnOpenContest(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!HasDatabase())
|
||||
{
|
||||
return;
|
||||
}
|
||||
ContestPickerDialog dialog = new(session.Store.Contests());
|
||||
int? chosen = await dialog.ShowDialog<int?>(this);
|
||||
if (chosen is not null)
|
||||
{
|
||||
session.OpenContest(chosen.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnExportCabrillo(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
Status("no contest is open");
|
||||
return;
|
||||
}
|
||||
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Export Cabrillo",
|
||||
SuggestedFileName = $"{session.Settings.Station.Callsign}.log",
|
||||
});
|
||||
if (file?.TryGetLocalPath() is not { } path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
CabrilloWriter writer = new(Logging.Contest, Logging.Me);
|
||||
CabrilloHeader header = new()
|
||||
{
|
||||
Contest = Logging.Contest.CabrilloName,
|
||||
Callsign = Logging.Me.Callsign,
|
||||
OperatorCategory = Logging.Instance.OperatorCategory,
|
||||
AssistedCategory = Logging.Instance.AssistedCategory,
|
||||
BandCategory = Logging.Instance.BandCategory,
|
||||
ModeCategory = Logging.Instance.ModeCategory,
|
||||
PowerCategory = Logging.Instance.PowerCategory,
|
||||
StationCategory = Logging.Instance.StationCategory,
|
||||
TransmitterCategory = Logging.Instance.TransmitterCategory,
|
||||
OverlayCategory = Logging.Instance.OverlayCategory,
|
||||
TimeCategory = Logging.Instance.TimeCategory,
|
||||
ClaimedScore = Logging.Log.TotalScore,
|
||||
Club = Logging.Me.Club,
|
||||
Name = Logging.Me.Name,
|
||||
Operators = Logging.Instance.Operators,
|
||||
Soapbox = Logging.Instance.Soapbox,
|
||||
};
|
||||
await File.WriteAllTextAsync(path, writer.Write(header, Logging.Log.Qsos));
|
||||
Status($"{Logging.Log.Qsos.Count} contacts written to {Path.GetFileName(path)}");
|
||||
}
|
||||
|
||||
private async void OnExportAdif(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
Status("no contest is open");
|
||||
return;
|
||||
}
|
||||
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Export ADIF",
|
||||
SuggestedFileName = $"{session.Settings.Station.Callsign}.adi",
|
||||
});
|
||||
if (file?.TryGetLocalPath() is not { } path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await File.WriteAllTextAsync(path, new AdifWriter(Logging.Me).Write(Logging.Log.Qsos));
|
||||
Status($"{Logging.Log.Qsos.Count} contacts written to {Path.GetFileName(path)}");
|
||||
}
|
||||
|
||||
private async void OnImportAdif(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
Status("no contest is open");
|
||||
return;
|
||||
}
|
||||
IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Import ADIF",
|
||||
AllowMultiple = false,
|
||||
});
|
||||
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
IReadOnlyList<Qso> read = AdifReader.Read(
|
||||
await File.ReadAllTextAsync(path),
|
||||
Logging.Contest.Name,
|
||||
Logging.Instance.ContestNumber);
|
||||
foreach (Qso qso in read)
|
||||
{
|
||||
session.Store.Add(qso);
|
||||
}
|
||||
session.OpenContest(Logging.Instance.ContestNumber);
|
||||
Status($"{read.Count} contacts read from {Path.GetFileName(path)}");
|
||||
}
|
||||
|
||||
private void OnExit(object? sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private void OnShowLog(object? sender, RoutedEventArgs e) => Show(() => new LogWindow(session));
|
||||
|
||||
private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session, () => Logging?.Entry.Call ?? ""));
|
||||
|
||||
private void OnShowBandmap(object? sender, RoutedEventArgs e) => Show(() => new BandmapWindow(session, Tune));
|
||||
|
||||
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
|
||||
|
||||
private void OnShowPacket(object? sender, RoutedEventArgs e) => Show(() => new PacketWindow(session));
|
||||
|
||||
private async void OnStationSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
StationDialog dialog = new(session.Settings.Station);
|
||||
StoredStation? updated = await dialog.ShowDialog<StoredStation?>(this);
|
||||
if (updated is not null)
|
||||
{
|
||||
session.Save(session.Settings with { Station = updated });
|
||||
if (Logging is not null)
|
||||
{
|
||||
session.OpenContest(Logging.Instance.ContestNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnRadioSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
RadioDialog dialog = new(session.Settings);
|
||||
Settings? updated = await dialog.ShowDialog<Settings?>(this);
|
||||
if (updated is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(updated);
|
||||
if (updated.RadioEnabled)
|
||||
{
|
||||
session.ConnectRadio();
|
||||
Status($"radio: rigctld at {updated.RigctldHost}:{updated.RigctldPort}");
|
||||
}
|
||||
else
|
||||
{
|
||||
session.DisconnectRadio();
|
||||
Status("radio disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnClusterSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
ClusterDialog dialog = new(session.Settings);
|
||||
Settings? updated = await dialog.ShowDialog<Settings?>(this);
|
||||
if (updated is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(updated);
|
||||
if (updated.ClusterEnabled)
|
||||
{
|
||||
session.ConnectCluster();
|
||||
Status($"cluster: {updated.ClusterHost}:{updated.ClusterPort}");
|
||||
}
|
||||
else
|
||||
{
|
||||
session.DisconnectCluster();
|
||||
Status("cluster disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnNetworkSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
NetworkDialog dialog = new(session.Settings);
|
||||
Settings? updated = await dialog.ShowDialog<Settings?>(this);
|
||||
if (updated is not null)
|
||||
{
|
||||
session.Save(updated);
|
||||
session.ApplyNetworkSettings();
|
||||
Status(updated.NetworkEnabled ? "networked with the other stations" : "networking off");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnDownloadCountryFile(object? sender, RoutedEventArgs e) =>
|
||||
await Download(
|
||||
downloader => downloader.DownloadCountryFileAsync(session.Paths.CountryFile),
|
||||
"country file");
|
||||
|
||||
private async void OnDownloadCallDatabase(object? sender, RoutedEventArgs e) =>
|
||||
await Download(
|
||||
downloader => downloader.DownloadCallDatabaseAsync(session.Paths.CallDatabaseFile),
|
||||
"callsign database");
|
||||
|
||||
private void OnReloadSupportFiles(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
session.ReloadSupportFiles();
|
||||
Status($"country file {(session.Countries is null ? "missing" : "loaded")}, " +
|
||||
$"{session.Calls.Count} callsigns");
|
||||
}
|
||||
|
||||
private async Task Download(Func<SupportFileDownloader, Task<string>> fetch, string what)
|
||||
{
|
||||
Status($"fetching the {what}…");
|
||||
try
|
||||
{
|
||||
string result = await fetch(new SupportFileDownloader(Http));
|
||||
session.ReloadSupportFiles();
|
||||
Status($"{what}: {result}");
|
||||
}
|
||||
catch (Exception error) when (error is InvalidOperationException or IOException)
|
||||
{
|
||||
Status(error.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void Tune(Frequency frequency, string call)
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Logging.Tune(frequency);
|
||||
_ = session.Radio?.TuneAsync(frequency);
|
||||
Logging.Entry.Call = call;
|
||||
SyncBoxes();
|
||||
boxes[0].Focus();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private bool HasDatabase()
|
||||
{
|
||||
if (session.Settings.DatabasePath.Length > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
Status("open a log database first: File ▸ New Database");
|
||||
return false;
|
||||
}
|
||||
|
||||
private void Show<T>(Func<T> create) where T : Window
|
||||
{
|
||||
if (openWindows.TryGetValue(typeof(T), out Window? existing))
|
||||
{
|
||||
existing.Activate();
|
||||
return;
|
||||
}
|
||||
T window = create();
|
||||
openWindows[typeof(T)] = window;
|
||||
window.Closed += (_, _) => openWindows.Remove(typeof(T));
|
||||
window.Show(this);
|
||||
}
|
||||
|
||||
private async Task<IStorageFolder?> Folder(string path) =>
|
||||
await StorageProvider.TryGetFolderFromPathAsync(path);
|
||||
}
|
||||
92
src/Nonemm.App/Windows/EntryWindow.axaml
Normal file
92
src/Nonemm.App/Windows/EntryWindow.axaml
Normal file
@@ -0,0 +1,92 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Windows.EntryWindow"
|
||||
Title="Nonemm"
|
||||
Width="620" SizeToContent="Height"
|
||||
CanResize="True"
|
||||
FontFamily="Inter, sans-serif">
|
||||
<Window.Styles>
|
||||
<Style Selector="TextBlock.label">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
<Setter Property="Margin" Value="2,0,0,1" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.entry">
|
||||
<Setter Property="FontFamily" Value="monospace" />
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.fkey">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Padding" Value="6,3" />
|
||||
<Setter Property="Margin" Value="0,0,3,0" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<DockPanel>
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="_File">
|
||||
<MenuItem Header="_New Database…" Click="OnNewDatabase" />
|
||||
<MenuItem Header="_Open Database…" Click="OnOpenDatabase" />
|
||||
<Separator />
|
||||
<MenuItem Header="New _Contest…" Click="OnNewContest" />
|
||||
<MenuItem Header="Open Con_test…" Click="OnOpenContest" />
|
||||
<Separator />
|
||||
<MenuItem Header="Export _Cabrillo…" Click="OnExportCabrillo" />
|
||||
<MenuItem Header="Export _ADIF…" Click="OnExportAdif" />
|
||||
<MenuItem Header="_Import ADIF…" Click="OnImportAdif" />
|
||||
<Separator />
|
||||
<MenuItem Header="E_xit" Click="OnExit" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_View">
|
||||
<MenuItem Header="_Log" Click="OnShowLog" />
|
||||
<MenuItem Header="_Check" Click="OnShowCheck" />
|
||||
<MenuItem Header="_Bandmap" Click="OnShowBandmap" />
|
||||
<MenuItem Header="_Score Summary" Click="OnShowScore" />
|
||||
<MenuItem Header="_Packet" Click="OnShowPacket" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Config">
|
||||
<MenuItem Header="_Station…" Click="OnStationSettings" />
|
||||
<MenuItem Header="_Radio…" Click="OnRadioSettings" />
|
||||
<MenuItem Header="_Cluster…" Click="OnClusterSettings" />
|
||||
<MenuItem Header="_Network…" Click="OnNetworkSettings" />
|
||||
<Separator />
|
||||
<MenuItem Header="Download Country _File" Click="OnDownloadCountryFile" />
|
||||
<MenuItem Header="Download Check _Partial File" Click="OnDownloadCallDatabase" />
|
||||
<MenuItem Header="Reload Support Files" Click="OnReloadSupportFiles" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Border DockPanel.Dock="Bottom" Padding="6,4" BorderThickness="0,1,0,0"
|
||||
BorderBrush="#33808080">
|
||||
<WrapPanel Name="FunctionKeys" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Margin="6">
|
||||
<Grid ColumnDefinitions="Auto,Auto,Auto,*,Auto" Margin="0,0,0,6">
|
||||
<TextBlock Name="FrequencyText" FontFamily="monospace" FontSize="20" Text="14025.00" />
|
||||
<TextBlock Grid.Column="1" Name="ModeText" FontFamily="monospace" FontSize="20"
|
||||
Margin="10,0,0,0" Text="CW" />
|
||||
<Border Grid.Column="2" Name="RunBorder" Margin="10,2" Padding="6,1" CornerRadius="3"
|
||||
Background="#22808080">
|
||||
<TextBlock Name="RunText" FontSize="12" Text="S&P" />
|
||||
</Border>
|
||||
<TextBlock Grid.Column="3" Name="ContestText" FontSize="12" Opacity="0.75"
|
||||
VerticalAlignment="Center" Margin="12,0,12,0" Text="no contest"
|
||||
TextTrimming="CharacterEllipsis" ClipToBounds="True" />
|
||||
<TextBlock Grid.Column="4" Name="ClockText" FontFamily="monospace" FontSize="20"
|
||||
Text="00:00:00Z" />
|
||||
</Grid>
|
||||
|
||||
<Grid Name="EntryGrid" ColumnDefinitions="Auto" RowDefinitions="Auto,Auto" />
|
||||
|
||||
<Border Name="VerdictBorder" Margin="0,6,0,0" Padding="6,3" CornerRadius="3"
|
||||
Background="#22808080">
|
||||
<TextBlock Name="VerdictText" FontSize="12" Text="" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" Margin="2,4,0,0" Text="" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
292
src/Nonemm.App/Windows/EntryWindow.axaml.cs
Normal file
292
src/Nonemm.App/Windows/EntryWindow.axaml.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The window the operator types in. Every key that does something in a contest
|
||||
/// is handled here; the decisions behind them are in `LoggingSession`.
|
||||
public sealed partial class EntryWindow : Window
|
||||
{
|
||||
private readonly AppSession session;
|
||||
private readonly List<TextBox> boxes = [];
|
||||
private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) };
|
||||
private readonly Dictionary<Type, Window> openWindows = [];
|
||||
private bool updating;
|
||||
|
||||
public EntryWindow(AppSession session)
|
||||
{
|
||||
this.session = session;
|
||||
InitializeComponent();
|
||||
BuildFunctionKeys();
|
||||
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
||||
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(BuildEntryBoxes);
|
||||
clock.Tick += (_, _) => UpdateClock();
|
||||
clock.Start();
|
||||
Opened += (_, _) => Reopen();
|
||||
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
|
||||
}
|
||||
|
||||
|
||||
private LoggingSession? Logging => session.Logging;
|
||||
|
||||
/// Reopens the database and contest the operator was last in.
|
||||
private void Reopen()
|
||||
{
|
||||
try
|
||||
{
|
||||
int contestNumber = session.Settings.ContestNumber;
|
||||
if (session.Settings.DatabasePath.Length > 0 && File.Exists(session.Settings.DatabasePath))
|
||||
{
|
||||
session.OpenDatabase(session.Settings.DatabasePath);
|
||||
if (contestNumber > 0)
|
||||
{
|
||||
session.OpenContest(contestNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is IOException or InvalidOperationException or KeyNotFoundException)
|
||||
{
|
||||
Status($"could not reopen the last log: {e.Message}");
|
||||
}
|
||||
BuildEntryBoxes();
|
||||
}
|
||||
|
||||
private void BuildEntryBoxes()
|
||||
{
|
||||
EntryGrid.Children.Clear();
|
||||
EntryGrid.ColumnDefinitions.Clear();
|
||||
boxes.Clear();
|
||||
if (Logging is null)
|
||||
{
|
||||
ContestText.Text = "no contest — File ▸ New Contest";
|
||||
Refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
AddBox("Call", 12, 0);
|
||||
for (int at = 0; at < Logging.Entry.Exchange.Count; at++)
|
||||
{
|
||||
ExchangeField field = Logging.Entry.Exchange[at];
|
||||
AddBox(field.Label, field.Width, at + 1);
|
||||
}
|
||||
boxes[0].Focus();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void AddBox(string label, int width, int index)
|
||||
{
|
||||
EntryGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
|
||||
TextBlock caption = new() { Text = label };
|
||||
caption.Classes.Add("label");
|
||||
Grid.SetColumn(caption, index);
|
||||
Grid.SetRow(caption, 0);
|
||||
EntryGrid.Children.Add(caption);
|
||||
|
||||
TextBox box = new()
|
||||
{
|
||||
Width = (width * 13) + 20,
|
||||
Margin = new Avalonia.Thickness(0, 0, 6, 0),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
};
|
||||
box.Classes.Add("entry");
|
||||
box.TextChanged += (_, _) => OnBoxChanged(index, box);
|
||||
box.GotFocus += (_, _) => Logging?.Entry.FocusOn(index);
|
||||
Grid.SetColumn(box, index);
|
||||
Grid.SetRow(box, 1);
|
||||
EntryGrid.Children.Add(box);
|
||||
boxes.Add(box);
|
||||
}
|
||||
|
||||
private void OnBoxChanged(int index, TextBox box)
|
||||
{
|
||||
if (updating || Logging is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Logging.Entry[index] = box.Text ?? "";
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Space when FocusedIsEntryBox():
|
||||
e.Handled = true;
|
||||
MoveFocus(forward: true);
|
||||
break;
|
||||
case Key.Enter:
|
||||
e.Handled = true;
|
||||
OnEnter();
|
||||
break;
|
||||
case Key.Escape:
|
||||
e.Handled = true;
|
||||
Logging.Wipe();
|
||||
SyncBoxes();
|
||||
boxes[0].Focus();
|
||||
break;
|
||||
case Key.Tab when FocusedIsEntryBox():
|
||||
e.Handled = true;
|
||||
MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
|
||||
break;
|
||||
case Key.OemQuestion when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||
e.Handled = true;
|
||||
ToggleRun();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnter()
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Frequency? qsy = Logging.PendingQsy();
|
||||
if (qsy is not null)
|
||||
{
|
||||
Logging.Tune(qsy.Value);
|
||||
_ = session.Radio?.TuneAsync(qsy.Value);
|
||||
Logging.Entry.Call = "";
|
||||
SyncBoxes();
|
||||
boxes[0].Focus();
|
||||
return;
|
||||
}
|
||||
if (!Logging.Entry.IsComplete)
|
||||
{
|
||||
MoveFocus(forward: true);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Qso logged = Logging.LogContact();
|
||||
Status($"logged {logged.Call} for {logged.Points} points");
|
||||
}
|
||||
catch (Exception e) when (e is InvalidOperationException or IOException)
|
||||
{
|
||||
Status($"could not log the contact: {e.Message}");
|
||||
return;
|
||||
}
|
||||
SyncBoxes();
|
||||
boxes[0].Focus();
|
||||
}
|
||||
|
||||
private void MoveFocus(bool forward)
|
||||
{
|
||||
if (Logging is null || boxes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (forward)
|
||||
{
|
||||
Logging.Entry.Advance();
|
||||
}
|
||||
else
|
||||
{
|
||||
Logging.Entry.Retreat();
|
||||
}
|
||||
boxes[Logging.Entry.Focus].Focus();
|
||||
boxes[Logging.Entry.Focus].CaretIndex = boxes[Logging.Entry.Focus].Text?.Length ?? 0;
|
||||
}
|
||||
|
||||
private bool FocusedIsEntryBox() => boxes.Any(b => b.IsFocused);
|
||||
|
||||
private void ToggleRun()
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Logging.IsRunning = !Logging.IsRunning;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void SyncBoxes()
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
updating = true;
|
||||
for (int at = 0; at < boxes.Count; at++)
|
||||
{
|
||||
boxes[at].Text = Logging.Entry[at];
|
||||
}
|
||||
updating = false;
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
VerdictText.Text = "";
|
||||
return;
|
||||
}
|
||||
FrequencyText.Text = Logging.Frequency.Kilohertz.ToString("0.00");
|
||||
ModeText.Text = Logging.Mode.Name;
|
||||
RunText.Text = Logging.IsRunning ? "RUN" : "S&P";
|
||||
RunBorder.Background = Logging.IsRunning ? Verdicts.Worth : new SolidColorBrush(Color.FromArgb(0x22, 0x80, 0x80, 0x80));
|
||||
ContestText.Text = ContestLine();
|
||||
|
||||
Verdict? verdict = Logging.Verdict();
|
||||
VerdictBorder.Background = Verdicts.Colour(verdict);
|
||||
VerdictText.Text = VerdictLine(verdict);
|
||||
VerdictText.Foreground = Brushes.White;
|
||||
foreach (Window window in openWindows.Values)
|
||||
{
|
||||
(window as RefreshableWindow)?.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private string ContestLine()
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
string mults = string.Join(
|
||||
" ",
|
||||
Logging.Contest.MultiplierNames.Select(
|
||||
(name, at) => $"{name} {Logging.Log.Tally.MultiplierCount(at + 1)}"));
|
||||
return $"{Logging.Contest.DisplayName} · {Logging.Log.Tally.Qsos} Q · " +
|
||||
$"{Logging.Log.Tally.Points} pts · {mults} · {Logging.Log.TotalScore:N0}";
|
||||
}
|
||||
|
||||
private string VerdictLine(Verdict? verdict)
|
||||
{
|
||||
if (Logging is null || Logging.Entry.Call.Trim().Length == 0)
|
||||
{
|
||||
return $"sending {Logging?.SentNumber}";
|
||||
}
|
||||
string country = Logging.Country() is { } found
|
||||
? $"{found.Entity.Name} · {found.Continent} · CQ {found.CqZone} · ITU {found.ItuZone}"
|
||||
: "unknown country";
|
||||
return $"{country} — {Verdicts.Describe(verdict)}";
|
||||
}
|
||||
|
||||
private void UpdateClock() => ClockText.Text = DateTime.UtcNow.ToString("HH:mm:ss") + "Z";
|
||||
|
||||
private void Status(string text) => StatusText.Text = text;
|
||||
|
||||
private void BuildFunctionKeys()
|
||||
{
|
||||
FunctionKeys.Children.Clear();
|
||||
foreach ((string key, string label, _) in Messages.Defaults)
|
||||
{
|
||||
Button button = new() { Content = $"{key} {label}" };
|
||||
button.Classes.Add("fkey");
|
||||
FunctionKeys.Children.Add(button);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/Nonemm.App/Windows/LogRow.cs
Normal file
59
src/Nonemm.App/Windows/LogRow.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using Avalonia.Media;
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// One line of the log window. It carries the colour so the log is coloured by
|
||||
/// the same scorer as the entry window and the bandmap.
|
||||
public sealed record LogRow(Qso Qso, Verdict? Verdict)
|
||||
{
|
||||
public string Time => Qso.TimestampUtc.ToString("MM-dd HH:mm");
|
||||
|
||||
public string Call => Qso.Call.Text;
|
||||
|
||||
public string Frequency => Qso.Frequency.Kilohertz.ToString("0.0");
|
||||
|
||||
public string Mode => Qso.Mode.Name;
|
||||
|
||||
public string Sent => $"{Qso.SentReport} {SentExchange()}".Trim();
|
||||
|
||||
public string Received => $"{Qso.ReceivedReport} {ReceivedExchange()}".Trim();
|
||||
|
||||
public string Country => Qso.CountryPrefix;
|
||||
|
||||
public int Points => Qso.Points;
|
||||
|
||||
public string Multipliers => string.Concat(
|
||||
Qso.IsMultiplier1 ? "1" : "",
|
||||
Qso.IsMultiplier2 ? "2" : "",
|
||||
Qso.IsMultiplier3 ? "3" : "");
|
||||
|
||||
public string Operator => Qso.Operator;
|
||||
|
||||
public IBrush Colour => Verdicts.Colour(Verdict);
|
||||
|
||||
private string SentExchange() =>
|
||||
Qso.SentNumber > 0 ? Qso.SentNumber.ToString() : "";
|
||||
|
||||
private string ReceivedExchange()
|
||||
{
|
||||
List<string> parts = [];
|
||||
if (Qso.ReceivedNumber > 0)
|
||||
{
|
||||
parts.Add(Qso.ReceivedNumber.ToString());
|
||||
}
|
||||
if (Qso.Zone > 0)
|
||||
{
|
||||
parts.Add(Qso.Zone.ToString());
|
||||
}
|
||||
foreach (string value in new[] { Qso.Section, Qso.Exchange1, Qso.Name, Qso.GridSquare, Qso.MiscText })
|
||||
{
|
||||
if (value.Length > 0)
|
||||
{
|
||||
parts.Add(value);
|
||||
}
|
||||
}
|
||||
return string.Join(' ', parts);
|
||||
}
|
||||
}
|
||||
26
src/Nonemm.App/Windows/LogWindow.axaml
Normal file
26
src/Nonemm.App/Windows/LogWindow.axaml
Normal file
@@ -0,0 +1,26 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
x:Class="Nonemm.App.Windows.LogWindow"
|
||||
Title="Log" Width="900" Height="420">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Bottom" Name="SummaryText" Margin="8,4" FontSize="11" Opacity="0.75" />
|
||||
<DataGrid Name="Rows" IsReadOnly="True" GridLinesVisibility="Horizontal"
|
||||
CanUserSortColumns="False" FontSize="12" FontFamily="monospace"
|
||||
x:DataType="local:LogRow">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Time" Binding="{Binding Time}" Width="125" />
|
||||
<DataGridTextColumn Header="Freq" Binding="{Binding Frequency}" Width="95" />
|
||||
<DataGridTextColumn Header="Mode" Binding="{Binding Mode}" Width="60" />
|
||||
<DataGridTextColumn Header="Call" Binding="{Binding Call}" Width="120"
|
||||
Foreground="{Binding Colour}" />
|
||||
<DataGridTextColumn Header="Sent" Binding="{Binding Sent}" Width="100" />
|
||||
<DataGridTextColumn Header="Received" Binding="{Binding Received}" Width="160" />
|
||||
<DataGridTextColumn Header="Cty" Binding="{Binding Country}" Width="70" />
|
||||
<DataGridTextColumn Header="Pts" Binding="{Binding Points}" Width="60" />
|
||||
<DataGridTextColumn Header="Mult" Binding="{Binding Multipliers}" Width="70" />
|
||||
<DataGridTextColumn Header="Op" Binding="{Binding Operator}" Width="*" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</local:RefreshableWindow>
|
||||
48
src/Nonemm.App/Windows/LogWindow.axaml.cs
Normal file
48
src/Nonemm.App/Windows/LogWindow.axaml.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The contacts of the contest in progress, newest last.
|
||||
public sealed partial class LogWindow : RefreshableWindow
|
||||
{
|
||||
private readonly AppSession session;
|
||||
|
||||
public LogWindow(AppSession session)
|
||||
{
|
||||
this.session = session;
|
||||
InitializeComponent();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
|
||||
public override void Refresh()
|
||||
{
|
||||
if (session.Logging is null)
|
||||
{
|
||||
Rows.ItemsSource = Array.Empty<LogRow>();
|
||||
SummaryText.Text = "no contest is open";
|
||||
return;
|
||||
}
|
||||
List<LogRow> rows = session.Logging.Log.Qsos
|
||||
.Select(q => new LogRow(q, VerdictFor(q)))
|
||||
.ToList();
|
||||
Rows.ItemsSource = rows;
|
||||
if (rows.Count > 0)
|
||||
{
|
||||
Rows.ScrollIntoView(rows[^1], null);
|
||||
}
|
||||
SummaryText.Text =
|
||||
$"{session.Logging.Log.Tally.Qsos} contacts · {session.Logging.Log.Tally.Points} points · " +
|
||||
$"{session.Logging.Log.Tally.TotalMultipliers} multipliers · score {session.Logging.Log.TotalScore:N0}";
|
||||
}
|
||||
|
||||
/// A logged contact is coloured by what it turned out to be worth, not by
|
||||
/// judging it again as if it were new.
|
||||
private static Contests.Verdict VerdictFor(Qso qso) =>
|
||||
new(
|
||||
IsDupe: false,
|
||||
Points: qso.Points,
|
||||
NewMultipliers: qso.IsMultiplier1 || qso.IsMultiplier2 || qso.IsMultiplier3
|
||||
? [new Contests.Multiplier(1, "", "")]
|
||||
: []);
|
||||
}
|
||||
17
src/Nonemm.App/Windows/PacketWindow.axaml
Normal file
17
src/Nonemm.App/Windows/PacketWindow.axaml
Normal file
@@ -0,0 +1,17 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
x:Class="Nonemm.App.Windows.PacketWindow"
|
||||
Title="Packet" Width="640" Height="420">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="6" Spacing="6">
|
||||
<TextBox Name="CommandBox" Width="520" Watermark="command to the node" />
|
||||
<Button Content="Send" Click="OnSend" IsDefault="True" />
|
||||
</StackPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Name="StateText" Margin="8,6,8,2" FontSize="11" Opacity="0.7" />
|
||||
<ScrollViewer Name="Scroller">
|
||||
<TextBlock Name="Traffic" FontFamily="monospace" FontSize="12" Margin="8,0"
|
||||
TextWrapping="NoWrap" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</local:RefreshableWindow>
|
||||
61
src/Nonemm.App/Windows/PacketWindow.axaml.cs
Normal file
61
src/Nonemm.App/Windows/PacketWindow.axaml.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The cluster node's traffic as it arrived, and a line to send commands on.
|
||||
public sealed partial class PacketWindow : RefreshableWindow
|
||||
{
|
||||
private const int LinesKept = 500;
|
||||
|
||||
private readonly AppSession session;
|
||||
private readonly Queue<string> lines = new();
|
||||
|
||||
public PacketWindow(AppSession session)
|
||||
{
|
||||
this.session = session;
|
||||
InitializeComponent();
|
||||
if (session.Cluster is not null)
|
||||
{
|
||||
session.Cluster.LineArrived += OnLine;
|
||||
}
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
if (session.Cluster is not null)
|
||||
{
|
||||
session.Cluster.LineArrived -= OnLine;
|
||||
}
|
||||
};
|
||||
Refresh();
|
||||
}
|
||||
|
||||
|
||||
public override void Refresh() =>
|
||||
StateText.Text = session.Cluster is null
|
||||
? "no cluster node — Config ▸ Cluster"
|
||||
: session.Cluster.IsConnected
|
||||
? $"connected to {session.Settings.ClusterHost}:{session.Settings.ClusterPort}"
|
||||
: "connecting…";
|
||||
|
||||
private void OnLine(object? sender, string line) => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
lines.Enqueue(line);
|
||||
while (lines.Count > LinesKept)
|
||||
{
|
||||
lines.Dequeue();
|
||||
}
|
||||
Traffic.Text = string.Join('\n', lines);
|
||||
Scroller.ScrollToEnd();
|
||||
Refresh();
|
||||
});
|
||||
|
||||
private async void OnSend(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (session.Cluster is null || CommandBox.Text is not { Length: > 0 } command)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await session.Cluster.SendAsync(command);
|
||||
CommandBox.Text = "";
|
||||
}
|
||||
}
|
||||
9
src/Nonemm.App/Windows/RefreshableWindow.cs
Normal file
9
src/Nonemm.App/Windows/RefreshableWindow.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// A window the entry window tells to redraw when the log changes.
|
||||
public abstract class RefreshableWindow : Window
|
||||
{
|
||||
public abstract void Refresh();
|
||||
}
|
||||
10
src/Nonemm.App/Windows/ScoreWindow.axaml
Normal file
10
src/Nonemm.App/Windows/ScoreWindow.axaml
Normal file
@@ -0,0 +1,10 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
x:Class="Nonemm.App.Windows.ScoreWindow"
|
||||
Title="Score summary" Width="480" SizeToContent="Height">
|
||||
<StackPanel Margin="10" Spacing="6">
|
||||
<Grid Name="Table" />
|
||||
<TextBlock Name="TotalText" FontSize="14" Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</local:RefreshableWindow>
|
||||
87
src/Nonemm.App/Windows/ScoreWindow.axaml.cs
Normal file
87
src/Nonemm.App/Windows/ScoreWindow.axaml.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using Avalonia.Controls;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// Contacts, points and multipliers band by band.
|
||||
public sealed partial class ScoreWindow : RefreshableWindow
|
||||
{
|
||||
private readonly AppSession session;
|
||||
|
||||
public ScoreWindow(AppSession session)
|
||||
{
|
||||
this.session = session;
|
||||
InitializeComponent();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
|
||||
public override void Refresh()
|
||||
{
|
||||
Table.Children.Clear();
|
||||
Table.ColumnDefinitions.Clear();
|
||||
Table.RowDefinitions.Clear();
|
||||
if (session.Logging is null)
|
||||
{
|
||||
TotalText.Text = "no contest is open";
|
||||
return;
|
||||
}
|
||||
|
||||
List<Band> bands = session.Logging.Log.Qsos
|
||||
.Select(q => q.Band)
|
||||
.OfType<Band>()
|
||||
.Distinct()
|
||||
.OrderBy(b => b.MegahertzLabel)
|
||||
.ToList();
|
||||
|
||||
foreach (string _ in new[] { "band", "qsos", "points", "mults" })
|
||||
{
|
||||
Table.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
|
||||
}
|
||||
AddRow(0, "Band", "QSOs", "Points", "Mults", header: true);
|
||||
|
||||
int row = 1;
|
||||
foreach (Band band in bands)
|
||||
{
|
||||
IReadOnlyList<Qso> onBand = session.Logging.Log.Qsos.Where(q => q.Band == band).ToList();
|
||||
AddRow(
|
||||
row++,
|
||||
band.Name,
|
||||
onBand.Count.ToString(),
|
||||
onBand.Sum(q => q.Points).ToString(),
|
||||
onBand.Sum(MultiplierCount).ToString());
|
||||
}
|
||||
AddRow(
|
||||
row,
|
||||
"Total",
|
||||
session.Logging.Log.Tally.Qsos.ToString(),
|
||||
session.Logging.Log.Tally.Points.ToString(),
|
||||
session.Logging.Log.Tally.TotalMultipliers.ToString(),
|
||||
header: true);
|
||||
|
||||
TotalText.Text = $"Claimed score {session.Logging.Log.TotalScore:N0}";
|
||||
}
|
||||
|
||||
private static int MultiplierCount(Qso qso) =>
|
||||
(qso.IsMultiplier1 ? 1 : 0) + (qso.IsMultiplier2 ? 1 : 0) + (qso.IsMultiplier3 ? 1 : 0);
|
||||
|
||||
private void AddRow(int row, string band, string qsos, string points, string mults, bool header = false)
|
||||
{
|
||||
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
string[] cells = [band, qsos, points, mults];
|
||||
for (int column = 0; column < cells.Length; column++)
|
||||
{
|
||||
TextBlock text = new()
|
||||
{
|
||||
Text = cells[column],
|
||||
FontFamily = new Avalonia.Media.FontFamily("monospace"),
|
||||
FontSize = 13,
|
||||
Opacity = header ? 1.0 : 0.85,
|
||||
Margin = new Avalonia.Thickness(0, 2, 8, 2),
|
||||
};
|
||||
Grid.SetRow(text, row);
|
||||
Grid.SetColumn(text, column);
|
||||
Table.Children.Add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/Nonemm.App/app.manifest
Normal file
18
src/Nonemm.App/app.manifest
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="Nonemm.App.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
158
src/Nonemm.Network/ContactMessage.cs
Normal file
158
src/Nonemm.Network/ContactMessage.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System.Globalization;
|
||||
using System.Xml.Linq;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Network;
|
||||
|
||||
/// One contact as N1MM broadcasts it: a `contactinfo` XML document. Writing
|
||||
/// N1MM's own message means a Nonemm station and an N1MM station can sit on the
|
||||
/// same network and see each other's contacts.
|
||||
public static class ContactMessage
|
||||
{
|
||||
/// N1MM sends the frequencies in units of ten hertz.
|
||||
private const long FrequencyUnit = 10;
|
||||
|
||||
public static string Write(Qso qso, string myCallsign, string stationName)
|
||||
{
|
||||
XElement root = new(
|
||||
"contactinfo",
|
||||
new XElement("app", "Nonemm"),
|
||||
new XElement("contestname", qso.ContestName),
|
||||
new XElement("contestnr", qso.ContestNumber),
|
||||
new XElement("timestamp", qso.TimestampUtc.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)),
|
||||
new XElement("mycall", myCallsign),
|
||||
new XElement("band", qso.Band?.MegahertzLabel ?? 0),
|
||||
new XElement("rxfreq", qso.Frequency.Hertz / FrequencyUnit),
|
||||
new XElement("txfreq", (qso.QsxFrequency.Hertz == 0 ? qso.Frequency.Hertz : qso.QsxFrequency.Hertz) / FrequencyUnit),
|
||||
new XElement("operator", qso.Operator),
|
||||
new XElement("mode", qso.Mode.Name),
|
||||
new XElement("call", qso.Call.Text),
|
||||
new XElement("countryprefix", qso.CountryPrefix),
|
||||
new XElement("wpxprefix", qso.WpxPrefix),
|
||||
new XElement("stationprefix", qso.StationPrefix),
|
||||
new XElement("continent", qso.Continent),
|
||||
new XElement("snt", qso.SentReport),
|
||||
new XElement("sntnr", qso.SentNumber),
|
||||
new XElement("rcv", qso.ReceivedReport),
|
||||
new XElement("rcvnr", qso.ReceivedNumber),
|
||||
new XElement("gridsquare", qso.GridSquare),
|
||||
new XElement("exchange1", qso.Exchange1),
|
||||
new XElement("section", qso.Section),
|
||||
new XElement("comment", qso.Comment),
|
||||
new XElement("qth", qso.Qth),
|
||||
new XElement("name", qso.Name),
|
||||
new XElement("power", qso.Power),
|
||||
new XElement("misctext", qso.MiscText),
|
||||
new XElement("zone", qso.Zone),
|
||||
new XElement("prec", qso.Precedence),
|
||||
new XElement("ck", qso.Check),
|
||||
new XElement("ismultiplier1", qso.IsMultiplier1 ? 1 : 0),
|
||||
new XElement("ismultiplier2", qso.IsMultiplier2 ? 1 : 0),
|
||||
new XElement("ismultiplier3", qso.IsMultiplier3 ? 1 : 0),
|
||||
new XElement("points", qso.Points),
|
||||
new XElement("radionr", qso.RadioNumber),
|
||||
new XElement("RoverLocation", qso.RoverLocation),
|
||||
new XElement("RadioInterfaced", qso.IsRadioInterfaced ? 1 : 0),
|
||||
new XElement("NetworkedCompNr", qso.NetworkedComputerNumber),
|
||||
new XElement("IsOriginal", qso.IsOriginal),
|
||||
new XElement("NetBiosName", stationName),
|
||||
new XElement("IsRunQSO", qso.IsRunQso ? 1 : 0),
|
||||
new XElement("StationName", stationName),
|
||||
new XElement("ID", qso.Id),
|
||||
new XElement("IsClaimedQso", qso.IsClaimed ? 1 : 0));
|
||||
return root.ToString();
|
||||
}
|
||||
|
||||
/// Null for a message that is not a contact, which is how the other N1MM
|
||||
/// message kinds on the same port are passed over.
|
||||
public static Qso? Read(string xml)
|
||||
{
|
||||
XElement root;
|
||||
try
|
||||
{
|
||||
root = XElement.Parse(xml);
|
||||
}
|
||||
catch (System.Xml.XmlException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (root.Name.LocalName != "contactinfo")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string call = Text(root, "call");
|
||||
if (call.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new Qso
|
||||
{
|
||||
Id = Text(root, "ID") is { Length: > 0 } id ? id : Qso.NewId(),
|
||||
TimestampUtc = Timestamp(root),
|
||||
Call = Callsign.Parse(call),
|
||||
Frequency = Frequency.FromHertz(Integer(root, "rxfreq") * FrequencyUnit),
|
||||
QsxFrequency = Frequency.FromHertz(Integer(root, "txfreq") * FrequencyUnit),
|
||||
Mode = Modes.Parse(Text(root, "mode")) ?? Modes.Cw,
|
||||
ContestName = Text(root, "contestname"),
|
||||
ContestNumber = (int)Integer(root, "contestnr"),
|
||||
SentReport = Text(root, "snt"),
|
||||
ReceivedReport = Text(root, "rcv"),
|
||||
SentNumber = (int)Integer(root, "sntnr"),
|
||||
ReceivedNumber = (int)Integer(root, "rcvnr"),
|
||||
Zone = (int)Integer(root, "zone"),
|
||||
Check = (int)Integer(root, "ck"),
|
||||
Precedence = Text(root, "prec"),
|
||||
Section = Text(root, "section"),
|
||||
Exchange1 = Text(root, "exchange1"),
|
||||
MiscText = Text(root, "misctext"),
|
||||
Comment = Text(root, "comment"),
|
||||
Name = Text(root, "name"),
|
||||
Qth = Text(root, "qth"),
|
||||
Power = Text(root, "power"),
|
||||
GridSquare = Text(root, "gridsquare"),
|
||||
RoverLocation = Text(root, "RoverLocation"),
|
||||
CountryPrefix = Text(root, "countryprefix"),
|
||||
StationPrefix = Text(root, "stationprefix"),
|
||||
WpxPrefix = Text(root, "wpxprefix"),
|
||||
Continent = Text(root, "continent"),
|
||||
Points = (int)Integer(root, "points"),
|
||||
IsMultiplier1 = Flag(root, "ismultiplier1"),
|
||||
IsMultiplier2 = Flag(root, "ismultiplier2"),
|
||||
IsMultiplier3 = Flag(root, "ismultiplier3"),
|
||||
IsRunQso = Flag(root, "IsRunQSO"),
|
||||
Operator = Text(root, "operator"),
|
||||
RadioNumber = (int)Integer(root, "radionr"),
|
||||
IsRadioInterfaced = Flag(root, "RadioInterfaced"),
|
||||
NetworkedComputerNumber = (int)Integer(root, "NetworkedCompNr"),
|
||||
StationName = Text(root, "StationName") is { Length: > 0 } station
|
||||
? station
|
||||
: Text(root, "NetBiosName"),
|
||||
// a contact that arrived over the network was made somewhere else
|
||||
IsOriginal = false,
|
||||
IsClaimed = Flag(root, "IsClaimedQso"),
|
||||
};
|
||||
}
|
||||
|
||||
private static string Text(XElement root, string name) =>
|
||||
root.Element(name)?.Value.Trim() ?? "";
|
||||
|
||||
private static long Integer(XElement root, string name) =>
|
||||
long.TryParse(Text(root, name), NumberStyles.Integer, CultureInfo.InvariantCulture, out long value)
|
||||
? value
|
||||
: 0;
|
||||
|
||||
private static bool Flag(XElement root, string name)
|
||||
{
|
||||
string text = Text(root, name);
|
||||
return text.Equals("true", StringComparison.OrdinalIgnoreCase) || text == "1";
|
||||
}
|
||||
|
||||
private static DateTime Timestamp(XElement root) =>
|
||||
DateTime.TryParse(
|
||||
Text(root, "timestamp"),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out DateTime when)
|
||||
? when
|
||||
: DateTime.UtcNow;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
105
src/Nonemm.Network/StationNetwork.cs
Normal file
105
src/Nonemm.Network/StationNetwork.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Network;
|
||||
|
||||
/// Keeps the stations of a multi-operator entry in step. Each contact is sent
|
||||
/// to every peer as it is logged, and contacts that arrive from a peer are
|
||||
/// handed to whoever owns the log.
|
||||
public sealed class StationNetwork : IDisposable
|
||||
{
|
||||
private readonly int port;
|
||||
private readonly string stationName;
|
||||
private readonly IReadOnlyList<IPEndPoint> peers;
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
private readonly UdpClient listener;
|
||||
private readonly UdpClient sender = new();
|
||||
private Task? loop;
|
||||
|
||||
public StationNetwork(int port, string stationName, IEnumerable<string> peerAddresses)
|
||||
{
|
||||
this.port = port;
|
||||
this.stationName = stationName;
|
||||
peers = peerAddresses.Select(a => Endpoint(a, port)).OfType<IPEndPoint>().ToList();
|
||||
listener = new UdpClient(new IPEndPoint(IPAddress.Any, port));
|
||||
sender.EnableBroadcast = true;
|
||||
}
|
||||
|
||||
public string StationName => stationName;
|
||||
|
||||
public IReadOnlyList<IPEndPoint> Peers => peers;
|
||||
|
||||
/// A contact another station logged. It arrives already scored; the log
|
||||
/// works its points and multipliers out again from the rules.
|
||||
public event EventHandler<Qso>? ContactArrived;
|
||||
|
||||
public event EventHandler<string>? Failed;
|
||||
|
||||
public void Start() => loop ??= Task.Run(() => ListenAsync(stopping.Token));
|
||||
|
||||
public async Task SendAsync(Qso qso, string myCallsign, CancellationToken cancellation = default)
|
||||
{
|
||||
byte[] message = Encoding.UTF8.GetBytes(ContactMessage.Write(qso, myCallsign, stationName));
|
||||
foreach (IPEndPoint peer in Destinations())
|
||||
{
|
||||
try
|
||||
{
|
||||
await sender.SendAsync(message, peer, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Failed?.Invoke(this, $"could not reach {peer}: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
stopping.Cancel();
|
||||
listener.Dispose();
|
||||
sender.Dispose();
|
||||
stopping.Dispose();
|
||||
}
|
||||
|
||||
/// With no peers named, the contact goes out as a broadcast, which is how a
|
||||
/// station that has just joined is found without configuring anything.
|
||||
private IEnumerable<IPEndPoint> Destinations() =>
|
||||
peers.Count > 0 ? peers : [new IPEndPoint(IPAddress.Broadcast, port)];
|
||||
|
||||
private async Task ListenAsync(CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
UdpReceiveResult received = await listener.ReceiveAsync(cancellation).ConfigureAwait(false);
|
||||
Qso? qso = ContactMessage.Read(Encoding.UTF8.GetString(received.Buffer));
|
||||
if (qso is not null && qso.StationName != stationName)
|
||||
{
|
||||
ContactArrived?.Invoke(this, qso);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Failed?.Invoke(this, e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IPEndPoint? Endpoint(string address, int defaultPort)
|
||||
{
|
||||
string[] parts = address.Split(':');
|
||||
if (!IPAddress.TryParse(parts[0].Trim(), out IPAddress? host))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
int port = parts.Length > 1 && int.TryParse(parts[1], out int given) ? given : defaultPort;
|
||||
return new IPEndPoint(host, port);
|
||||
}
|
||||
}
|
||||
25
src/Nonemm.Session/CheckCandidate.cs
Normal file
25
src/Nonemm.Session/CheckCandidate.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core.Calls;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// One callsign the check window offers, with what the log says about it so it
|
||||
/// is coloured the same as everywhere else.
|
||||
public sealed record CheckCandidate(string Call, PartialMatchQuality Quality, Verdict? Verdict);
|
||||
|
||||
/// Where a column of candidates came from. They stay apart because they answer
|
||||
/// different questions: the log is what this station copied itself, the
|
||||
/// database is a guess about the whole world, and the bandmap is somebody
|
||||
/// else's claim.
|
||||
public enum CheckSource
|
||||
{
|
||||
Log,
|
||||
Database,
|
||||
Bandmap,
|
||||
}
|
||||
|
||||
/// One column of the check window.
|
||||
public sealed record CheckColumn(
|
||||
CheckSource Source,
|
||||
int Held,
|
||||
IReadOnlyList<CheckCandidate> Candidates);
|
||||
86
src/Nonemm.Session/CheckWindowSources.cs
Normal file
86
src/Nonemm.Session/CheckWindowSources.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// Answers what the callsign being typed could be, a column per source.
|
||||
public sealed class CheckWindowSources
|
||||
{
|
||||
private readonly LoggingSession session;
|
||||
private readonly CallDatabase database;
|
||||
private readonly Bandmap bandmap;
|
||||
|
||||
public CheckWindowSources(LoggingSession session, CallDatabase database, Bandmap bandmap)
|
||||
{
|
||||
this.session = session;
|
||||
this.database = database;
|
||||
this.bandmap = bandmap;
|
||||
}
|
||||
|
||||
public IReadOnlyList<CheckColumn> Columns(string typed, int limit = 24)
|
||||
{
|
||||
string query = typed.Trim().ToUpperInvariant();
|
||||
return
|
||||
[
|
||||
Column(CheckSource.Log, LoggedCalls(), query, limit),
|
||||
new CheckColumn(
|
||||
CheckSource.Database,
|
||||
database.Count,
|
||||
query.Length < PartialMatching.ShortestUsefulQuery
|
||||
? []
|
||||
: database.Matches(query, limit).Select(Judged).ToList()),
|
||||
Column(CheckSource.Bandmap, SpottedCalls(), query, limit),
|
||||
];
|
||||
}
|
||||
|
||||
private CheckColumn Column(
|
||||
CheckSource source,
|
||||
IReadOnlyList<string> calls,
|
||||
string query,
|
||||
int limit)
|
||||
{
|
||||
if (query.Length < PartialMatching.ShortestUsefulQuery)
|
||||
{
|
||||
return new CheckColumn(source, calls.Count, []);
|
||||
}
|
||||
List<CheckCandidate> found = [];
|
||||
foreach (string call in calls)
|
||||
{
|
||||
PartialMatchQuality? quality = PartialMatching.Judge(query, call);
|
||||
if (quality is not null)
|
||||
{
|
||||
found.Add(Judged(new PartialMatch(call, quality.Value)));
|
||||
}
|
||||
}
|
||||
return new CheckColumn(
|
||||
source,
|
||||
calls.Count,
|
||||
found.OrderBy(c => c.Quality).ThenBy(c => c.Call, StringComparer.Ordinal).Take(limit).ToList());
|
||||
}
|
||||
|
||||
private CheckCandidate Judged(PartialMatch match) =>
|
||||
new(match.Call, match.Quality, VerdictFor(match.Call));
|
||||
|
||||
/// What logging this call on the current band and mode would do.
|
||||
private Verdict? VerdictFor(string call)
|
||||
{
|
||||
Qso candidate = new()
|
||||
{
|
||||
Id = "",
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
Call = Callsign.Parse(call),
|
||||
Frequency = session.Frequency,
|
||||
Mode = session.Mode,
|
||||
ContestName = session.Contest.Name,
|
||||
};
|
||||
return session.Log.Judge(candidate);
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> LoggedCalls() =>
|
||||
session.Log.Qsos.Select(q => q.Call.Text).Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
private IReadOnlyList<string> SpottedCalls() =>
|
||||
bandmap.All().Select(s => s.Call.Text).Distinct(StringComparer.Ordinal).ToList();
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
<ProjectReference Include="..\Nonemm.Contests\Nonemm.Contests.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Storage\Nonemm.Storage.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Formats\Nonemm.Formats.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Spotting\Nonemm.Spotting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
Reference in New Issue
Block a user