Add the Broadcast Data dialog and send N1MM's score breakdown

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 03:42:19 +00:00
co-authored by Claude Sonnet 5.5
parent ec12fb1d0f
commit 65178b2d89
24 changed files with 1325 additions and 21 deletions
+218
View File
@@ -0,0 +1,218 @@
using Avalonia.Threading;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Network;
using Nonemm.Rig;
using Nonemm.Session;
using Nonemm.Spotting;
using Nonemm.Storage;
namespace Nonemm.App;
/// The data N1MM sends to other programs besides the contacts: what the
/// Broadcast Data tab turns on.
public sealed partial class AppSession
{
/// N1MM sends the score from the timer of the first entry window, every ten
/// seconds.
private static readonly TimeSpan ScoreInterval = TimeSpan.FromSeconds(10);
private DataBroadcaster? data;
private DispatcherTimer? broadcasting;
private readonly Dictionary<int, RadioReport> radiosSent = [];
private DateTime scoreSent;
private string lookupSent = "";
/// Starts, restarts or stops the broadcasts, following what the settings
/// now say. Rows whose addresses cannot be read are reported and left off.
public void ApplyBroadcastSettings()
{
broadcasting?.Stop();
data?.Dispose();
data = new DataBroadcaster(Settings.BroadcastChannels());
data.Failed += (_, problem) => Reported?.Invoke(this, problem);
foreach (string problem in data.Problems)
{
Reported?.Invoke(this, problem);
}
radiosSent.Clear();
scoreSent = DateTime.UtcNow;
lookupSent = "";
SendAppInfo();
broadcasting = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
broadcasting.Tick += (_, _) =>
{
SendRadios();
SendScore();
};
broadcasting.Start();
}
/// Which database and contest this station has open. It goes out when a
/// contest is opened.
private void SendAppInfo()
{
if (data is null || !data.IsSending(BroadcastKind.AppInfo) || Logging is null)
{
return;
}
data.Send(
BroadcastKind.AppInfo,
BroadcastMessages.AppInfo(
Settings.DatabasePath,
Logging.Instance.ContestNumber,
Logging.Contest.Name,
ComputerName,
Settings.Station.Callsign));
}
/// Each radio, when something about it has changed since the last time.
private void SendRadios()
{
if (data is null || !data.IsSending(BroadcastKind.Radio))
{
return;
}
foreach (OperatingPosition position in positions)
{
Radio? radio = radios.FirstOrDefault(r => r.Number == position.RadioNumber);
long transmit = position.TransmitFrequency.Hertz > 0
? position.TransmitFrequency.Hertz
: position.Frequency.Hertz;
RadioReport report = new(
position.RadioNumber,
position.Frequency.Hertz,
transmit,
position.Mode.Name,
Settings.Station.Callsign,
Settings.CurrentOperator,
position.IsRunning,
ActiveRadioNumber,
position.TransmitFrequency.Hertz > 0,
ActiveRadioNumber,
false,
"",
radio?.IsConnected ?? false);
if (!radiosSent.TryGetValue(position.RadioNumber, out RadioReport? last) || last != report)
{
radiosSent[position.RadioNumber] = report;
data.Send(BroadcastKind.Radio, BroadcastMessages.Radio(report, ComputerName));
}
}
}
private void SendSpots(IEnumerable<Spot> spots)
{
if (data is null || !data.IsSending(BroadcastKind.Spots))
{
return;
}
foreach (Spot spot in spots.Where(s => s.IsStation))
{
data.Send(
BroadcastKind.Spots,
BroadcastMessages.Spot(
new SpotReport(
spot.Call.Text,
spot.Frequency.Hertz,
spot.Spotter,
spot.AtUtc,
SpotMode(spot.Frequency),
spot.Comment,
SpotStatus(spot)),
"add",
ComputerName));
}
}
/// The colour the bandmap would give the station, as N1MM names it.
private string SpotStatus(Spot spot) => JudgeSpot(spot) switch
{
{ IsDupe: true } => "dupe",
{ NewMultipliers.Count: 1 } => "single mult",
{ NewMultipliers.Count: > 1 } => "double mult",
_ => "new qso",
};
private string SpotMode(Frequency frequency) => BandPlan.ModeAt(frequency) switch
{
ModeCategory.Phone => frequency.Megahertz < 10 ? "LSB" : "USB",
ModeCategory.Digital => "RTTY",
_ => "CW",
};
private void SendScore()
{
if (data is null
|| !data.IsSending(BroadcastKind.Score)
|| Logging is null
|| DateTime.UtcNow - scoreSent < ScoreInterval)
{
return;
}
scoreSent = DateTime.UtcNow;
ContestInstance instance = Logging.Instance;
StationInfo me = Settings.Station.ToStationInfo();
IReadOnlyList<string> multiplierNames = N1mmMultiplierNames.For(Logging.Contest, me);
List<ScoreReportRow> rows = ScoreBreakdown.For(Logging.Log.Qsos, Logging.Contest)
.Select(r => new ScoreReportRow(r.Band, r.Mode, r.Qsos, r.Multipliers, r.Points))
.ToList();
data.Send(
BroadcastKind.Score,
BroadcastMessages.Score(new ScoreReport(
ScoreContestName(instance),
Settings.Station.Callsign,
instance.Operators,
instance.PowerCategory,
instance.AssistedCategory,
instance.TransmitterCategory,
instance.OperatorCategory,
instance.BandCategory,
instance.ModeCategory,
instance.OverlayCategory,
instance.StationCategory,
Settings.Station.Club,
Settings.Station.CountryPrefix,
Settings.Station.CqZone,
Settings.Station.ItuZone,
Settings.Station.ArrlSection,
Settings.Station.State.Length > 0 ? Settings.Station.State : Settings.Station.Province,
Settings.Station.GridSquare,
multiplierNames,
rows,
Logging.Log.TotalScore,
DateTime.UtcNow)));
}
/// The state QSO parties carry the state in front of the Cabrillo name.
private string ScoreContestName(ContestInstance instance) =>
Logging!.Contest.Name == "QSOPARTY"
? $"{instance.SubType}-{Logging.Contest.CabrilloName}"
: Logging.Contest.CabrilloName;
/// The callsign being typed, for a program that looks it up. It goes out
/// when the operator leaves the callsign box with a call that has not been
/// sent yet.
public void SendLookup(OperatingPosition position)
{
string call = position.Entry.Call.Trim();
if (data is null || !data.IsSending(BroadcastKind.Lookup) || call.Length == 0 || call == lookupSent)
{
return;
}
lookupSent = call;
data.Send(
BroadcastKind.Lookup,
ContactMessage.WriteLookup(position.InProgress(), Settings.Station.Callsign, ComputerName));
}
/// A contact another logging computer made, passed on to the programs that
/// read the contact broadcast. Edits and deletes are not passed on.
private void ForwardToPrograms(ContactUpdate update)
{
if (Settings.BroadcastAllComputers && update is ContactLogged logged)
{
_ = network?.SendForwardedAsync(logged.Qso, Settings.Station.Callsign);
}
}
}
+12 -4
View File
@@ -18,7 +18,7 @@ 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
public sealed partial class AppSession : IDisposable
{
/// How often this station tells the others how it is doing. N1MM sends its
/// status on the same sort of interval; every second would read the log
@@ -350,6 +350,7 @@ public sealed class AppSession : IDisposable
Available = new AvailableStations(positions[0], Bandmap);
Save(Settings with { ContestNumber = contestNumber });
ContestChanged?.Invoke(this, EventArgs.Empty);
SendAppInfo();
}
/// A frequency on the band a number was taken on. The message carries a
@@ -412,6 +413,7 @@ public sealed class AppSession : IDisposable
// the socket thread must not touch the log: every other change to it is
// made where the windows read it
link.UpdateArrived += (_, update) => QueueFromNetwork(update);
link.UpdateArrived += (_, update) => ForwardToPrograms(update);
// the resync reads the log rather than being handed it, and another
// station can ask for a rescore when its resync is done
link.Log = new SessionContactLog(this);
@@ -967,13 +969,17 @@ public sealed class AppSession : IDisposable
if (keeping.Count > 0)
{
Bandmap.AddRange(keeping);
SendSpots(keeping);
}
}
/// Whether the station has already been worked, which decides whether a
/// spot is moved about: N1MM leaves a dupe where it is.
private bool IsDupe(Spot spot) =>
Logging is not null && Logging.Log.Judge(new Qso
private bool IsDupe(Spot spot) => JudgeSpot(spot) is { IsDupe: true };
/// What logging the station of a spot would do, or null with no contest open.
private Verdict? JudgeSpot(Spot spot) =>
Logging?.Log.Judge(new Qso
{
Id = "",
TimestampUtc = spot.AtUtc,
@@ -981,7 +987,7 @@ public sealed class AppSession : IDisposable
Frequency = spot.Frequency,
Mode = Position?.Mode ?? Modes.Cw,
ContestName = Logging.Contest.Name,
}) is { IsDupe: true };
});
private void ApplySpotSettings()
{
@@ -1010,6 +1016,8 @@ public sealed class AppSession : IDisposable
DisposeRadios();
cluster?.Dispose();
announcing?.Stop();
broadcasting?.Stop();
data?.Dispose();
network?.Dispose();
link?.Dispose();
alternating?.Dispose();
+29
View File
@@ -130,6 +130,35 @@ public sealed record Settings
public IReadOnlyList<string> NetworkPeers { get; init; } = [];
/// Also send the contacts the other logging computers make to the programs
/// that read the contact broadcast. N1MM's All Computers.
public bool BroadcastAllComputers { get; init; }
/// The other rows of Config > Broadcast Data. The contacts row is
/// `NetworkEnabled` and `NetworkPeers`.
public BroadcastChannel BroadcastAppInfo { get; init; } = new();
public BroadcastChannel BroadcastRadio { get; init; } = new();
public BroadcastChannel BroadcastSpots { get; init; } = new();
public BroadcastChannel BroadcastRotor { get; init; } = new();
public BroadcastChannel BroadcastScore { get; init; } = new();
public BroadcastChannel BroadcastLookup { get; init; } = new();
public IReadOnlyDictionary<BroadcastKind, BroadcastChannel> BroadcastChannels() =>
new Dictionary<BroadcastKind, BroadcastChannel>
{
[BroadcastKind.AppInfo] = BroadcastAppInfo,
[BroadcastKind.Radio] = BroadcastRadio,
[BroadcastKind.Spots] = BroadcastSpots,
[BroadcastKind.Rotor] = BroadcastRotor,
[BroadcastKind.Score] = BroadcastScore,
[BroadcastKind.Lookup] = BroadcastLookup,
};
/// The link to the other logging computers of a multi-operator entry, on
/// N1MM's port 12070. It is not the same thing as `NetworkEnabled`, which
/// is the XML broadcast on 12060 that other programs read; a station can
@@ -0,0 +1,35 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.BroadcastDataDialog"
Title="Broadcast Data" Width="680" SizeToContent="Height"
WindowStartupLocation="CenterOwner" CanResize="False">
<StackPanel Margin="14" Spacing="6">
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="Select the type of data you wish to broadcast, and the IP address(es) and port(s) for the receiver(s) of the data. Use 127.0.0.1 for the local machine. Use 12060 as the port unless the receiving application requires a different port. 255 in the low order octet will broadcast to your current subnet." />
<TextBlock Text="Type of data IP Addr:Port IP Addr:Port..."
FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
<Grid ColumnDefinitions="150,8,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="6">
<CheckBox Name="AppInfoCheck" Content="Application Info" />
<TextBox Name="AppInfoBox" Grid.Column="2" />
<CheckBox Name="RadioCheck" Grid.Row="1" Content="Radio" />
<TextBox Name="RadioBox" Grid.Row="1" Grid.Column="2" />
<CheckBox Name="ContactsCheck" Grid.Row="2" Content="Contacts" />
<TextBox Name="ContactsBox" Grid.Row="2" Grid.Column="2"
PlaceholderText="empty: broadcast to the whole network" />
<CheckBox Name="AllComputersCheck" Grid.Row="3" Content="All Computers" Margin="20,0,0,0" />
<CheckBox Name="SpotsCheck" Grid.Row="4" Content="Spots" />
<TextBox Name="SpotsBox" Grid.Row="4" Grid.Column="2" />
<CheckBox Name="RotorCheck" Grid.Row="5" Content="Rotor" />
<TextBox Name="RotorBox" Grid.Row="5" Grid.Column="2" />
<CheckBox Name="ScoreCheck" Grid.Row="6" Content="Score" />
<TextBox Name="ScoreBox" Grid.Row="6" Grid.Column="2" />
<CheckBox Name="LookupCheck" Grid.Row="7" Content="External Callsign Lookup" />
<TextBox Name="LookupBox" Grid.Row="7" Grid.Column="2" />
</Grid>
<TextBlock Name="ProblemText" TextWrapping="Wrap" Foreground="OrangeRed" IsVisible="False" />
<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>
@@ -0,0 +1,82 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Nonemm.App.Configuration;
using Nonemm.Network;
namespace Nonemm.App.Dialogs;
/// N1MM's Broadcast Data tab: what is sent to other programs, and where.
public sealed partial class BroadcastDataDialog : Window
{
private readonly Settings settings;
public BroadcastDataDialog(Settings settings)
{
this.settings = settings;
InitializeComponent();
Fill(AppInfoCheck, AppInfoBox, BroadcastKind.AppInfo, settings.BroadcastAppInfo);
Fill(RadioCheck, RadioBox, BroadcastKind.Radio, settings.BroadcastRadio);
Fill(SpotsCheck, SpotsBox, BroadcastKind.Spots, settings.BroadcastSpots);
Fill(RotorCheck, RotorBox, BroadcastKind.Rotor, settings.BroadcastRotor);
Fill(ScoreCheck, ScoreBox, BroadcastKind.Score, settings.BroadcastScore);
Fill(LookupCheck, LookupBox, BroadcastKind.Lookup, settings.BroadcastLookup);
ContactsCheck.IsChecked = settings.NetworkEnabled;
ContactsBox.Text = string.Join(' ', settings.NetworkPeers);
AllComputersCheck.IsChecked = settings.BroadcastAllComputers;
}
/// An empty list shows the default, as N1MM does.
private static void Fill(CheckBox check, TextBox box, BroadcastKind kind, BroadcastChannel channel)
{
check.IsChecked = channel.IsEnabled;
box.Text = channel.Addresses.Count > 0
? string.Join(' ', channel.Addresses)
: BroadcastTargets.DefaultFor(kind).ToString();
}
private void OnSave(object? sender, RoutedEventArgs e)
{
List<string> problems = [];
BroadcastChannel Read(string name, CheckBox check, TextBox box, int defaultPort)
{
string text = (box.Text ?? "").Trim();
if (BroadcastTargets.Parse(text, defaultPort, out string problem) is null)
{
problems.Add($"{name}: {problem}");
}
return new BroadcastChannel
{
IsEnabled = check.IsChecked == true,
Addresses = text.Split([' ', ',', ';', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries),
};
}
BroadcastChannel appInfo = Read("Application Info", AppInfoCheck, AppInfoBox, BroadcastTargets.DefaultPort);
BroadcastChannel radio = Read("Radio", RadioCheck, RadioBox, BroadcastTargets.DefaultPort);
BroadcastChannel contacts = Read("Contacts", ContactsCheck, ContactsBox, settings.NetworkPort);
BroadcastChannel spots = Read("Spots", SpotsCheck, SpotsBox, BroadcastTargets.DefaultPort);
BroadcastChannel rotor = Read("Rotor", RotorCheck, RotorBox, BroadcastTargets.RotorPort);
BroadcastChannel score = Read("Score", ScoreCheck, ScoreBox, BroadcastTargets.ScorePort);
BroadcastChannel lookup = Read("External Callsign Lookup", LookupCheck, LookupBox, BroadcastTargets.DefaultPort);
if (problems.Count > 0)
{
ProblemText.Text = string.Join("\n", problems);
ProblemText.IsVisible = true;
return;
}
Close(settings with
{
BroadcastAppInfo = appInfo,
BroadcastRadio = radio,
NetworkEnabled = contacts.IsEnabled,
NetworkPeers = contacts.Addresses,
BroadcastAllComputers = AllComputersCheck.IsChecked == true,
BroadcastSpots = spots,
BroadcastRotor = rotor,
BroadcastScore = score,
BroadcastLookup = lookup,
});
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}
+1 -4
View File
@@ -5,16 +5,13 @@
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." />
Text="This computer's name on the network and the port it listens on for contacts. Where contacts are sent is set in Config ▸ Configure Broadcast Data." />
<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" PlaceholderText="192.168.1.11" />
<CheckBox Name="EnabledBox" Content="Broadcast contacts to other programs (port 12060)" Margin="0,6,0,0" />
<TextBlock Text="The other logging computers" FontWeight="Bold" Margin="0,12,0,0" />
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="This is N1MM's own link between the computers of one entry, on port 12070: contacts, edits, deletes and chat, over a connection to each station. Stations are found by broadcast, so nothing has to be listed." />
@@ -16,8 +16,6 @@ public sealed partial class NetworkDialog : Window
? settings.NetworkStationName
: Environment.MachineName;
PortBox.Text = settings.NetworkPort.ToString();
PeersBox.Text = string.Join("\n", settings.NetworkPeers);
EnabledBox.IsChecked = settings.NetworkEnabled;
StationNumberBox.Text = settings.StationNumber.ToString();
LinkPortBox.Text = settings.StationLinkPort.ToString();
VersionBox.Text = settings.StationLinkVersion;
@@ -30,12 +28,6 @@ public sealed partial class NetworkDialog : Window
{
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,
StationNumber = int.TryParse(StationNumberBox.Text, out int number)
? Math.Clamp(number, 1, 99)
: settings.StationNumber,
@@ -568,6 +568,19 @@ public sealed partial class EntryWindow
}
}
private async void OnBroadcastSettings(object? sender, RoutedEventArgs e)
{
BroadcastDataDialog dialog = new(session.Settings);
Settings? updated = await dialog.ShowDialog<Settings?>(this);
if (updated is not null)
{
session.Save(updated);
session.ApplyNetworkSettings();
session.ApplyBroadcastSettings();
Status("broadcast data saved");
}
}
/// What the two networks are now doing, for the status line.
private static string Networking(Settings settings) =>
(settings.NetworkEnabled, settings.StationLinkEnabled) switch
+1
View File
@@ -114,6 +114,7 @@
<MenuItem Header="Change Band Plan…" Click="OnSubBandSettings" />
<MenuItem Header="Manage Skins, Colors and Fonts…" Click="OnThemeSettings" />
<MenuItem Header="Edit Networked-Computer Names…" Click="OnNetworkSettings" />
<MenuItem Header="Configure Broadcast Data…" Click="OnBroadcastSettings" />
<MenuItem Header="WAE">
<MenuItem Header="Open QTC Window setup area" Click="OnQtcSetup" />
</MenuItem>
@@ -202,6 +202,10 @@ public sealed partial class EntryWindow : Window
{
yield return ("station link", session.ApplyStationLinkSettings);
}
if (session.Settings.BroadcastChannels().Values.Any(c => c.IsEnabled))
{
yield return ("data broadcast", session.ApplyBroadcastSettings);
}
// always, whatever the CW keyer is: this is also what builds the voice
// keyer, which plays out of the sound card rather than a keying port
yield return ("keyer", session.ApplyKeyerSettings);
@@ -363,6 +367,10 @@ public sealed partial class EntryWindow : Window
break;
case Key.Tab when FocusedIsEntryBox():
e.Handled = true;
if (Logging is not null && boxes[0].IsFocused)
{
session.SendLookup(Logging);
}
MoveToNextBox(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
break;
case Key.Z when e.KeyModifiers.HasFlag(KeyModifiers.Control):
@@ -0,0 +1,62 @@
using Nonemm.Core;
namespace Nonemm.Contests;
/// The multiplier names N1MM keeps in the `Contest` table, such as `ZN`,
/// `CountryPrefix` and `Sect`. The score broadcast names its multiplier types
/// from them, and a contest's own `MultiplierNamesFor` holds the names the
/// score window shows instead (`Zones`, `Countries`). Taken from a log N1MM
/// wrote, without the `N/A` entries.
public static class N1mmMultiplierNames
{
private static readonly Dictionary<string, IReadOnlyList<string>> Known =
new(StringComparer.OrdinalIgnoreCase)
{
["CQWWCW"] = ["ZN", "CountryPrefix"],
["CQWWSSB"] = ["ZN", "CountryPrefix"],
["CQWWRTTY"] = ["ZN", "CountryPrefix", "Sect"],
["CQWPXCW"] = ["WPXPrefix"],
["CQWPXSSB"] = ["WPXPrefix"],
["CQWPXRTTY"] = ["WPXPrefix"],
["ARRLDXCW"] = ["Sect"],
["ARRLDXSSB"] = ["Sect"],
["IARU"] = ["ZN", "HQ"],
["SSCW"] = ["Sect"],
["SSSSB"] = ["Sect"],
["WAECW"] = ["CountryPrefix"],
["WAESSB"] = ["CountryPrefix"],
["WAERTTY"] = ["CountryPrefix"],
["ARRLRTTY"] = ["Sect", "CountryPrefix"],
["NAQPCW"] = ["Sect", "NA"],
["NAQPSSB"] = ["Sect", "NA"],
["NAQPRTTY"] = ["Sect", "NA"],
["OKOMDX"] = ["Sect"],
["YOTA"] = ["Exchange"],
["IOTA"] = ["Sect"],
["EUDXC"] = ["Sect", "CountryPrefix"],
["CQ160CW"] = ["StateProvince", "CountryPrefix"],
["CQ160SSB"] = ["StateProvince", "CountryPrefix"],
["XERTTY"] = ["Countryprefix", "Sect"],
["REFCW"] = ["Sect", "CountryPrefix"],
["REFSSB"] = ["Sect", "CountryPrefix"],
["UKRAINDX"] = ["CountryPrefix", "Sect"],
["BARTGRTTYS"] = ["CountryPrefix", "Sect", "Continent"],
["ARRL10M"] = ["StateProvince", "CountryPrefix"],
["RUSDXRTTY"] = ["CountryPrefix", "Sect"],
["RDAC"] = ["CountryPrefix", "Sect"],
["YOHFDX"] = ["CountryPrefix", "Sect"],
["OCEANIACW"] = ["WPXPrefix"],
["OCEANIASSB"] = ["WPXPrefix"],
["FDREG1"] = ["CountryPrefix"],
["SARTGRTTY"] = ["CountryPrefix", "Sect"],
["ALLASIACW"] = ["WPXPrefix"],
["ALLASIASSB"] = ["WPXPrefix"],
["DX"] = ["CountryPrefix"],
};
/// A contest not in the table gets the names its rules give.
public static IReadOnlyList<string> For(Contest contest, StationInfo me) =>
Known.TryGetValue(contest.Name, out IReadOnlyList<string>? names)
? names
: contest.MultiplierNamesFor(me);
}
+72
View File
@@ -0,0 +1,72 @@
using System.Globalization;
using Nonemm.Core;
namespace Nonemm.Contests;
/// One row of N1MM's score query: the contacts of one band and mode, or of all
/// of them when `Band` is `ScoreBreakdown.TotalBand`. `Kind` is empty except in
/// WAE, which gives contacts and traffic a row each.
public sealed record ScoreBreakdownRow(
double Band,
string Mode,
string Kind,
int Qsos,
int Points,
IReadOnlyList<int> Multipliers);
/// The score the way N1MM's database query splits it: a row per band and mode
/// of the contact, with the multiplier flags added up, and a total row last
/// when there is any contact.
/// `ContestLog.Rows` splits by band alone, which is what the score window shows.
public static class ScoreBreakdown
{
/// The band N1MM gives the total row.
public const double TotalBand = 900_000_000;
public const string TotalMode = "Both";
/// Ordered the way the query orders them: by the band as text, so `10.0`
/// comes before `3.5`, then by mode. Only contacts in the claimed score
/// are counted.
public static IReadOnlyList<ScoreBreakdownRow> For(IEnumerable<Qso> qsos, Contest contest)
{
List<Qso> counted = qsos.Where(q => q.IsInScore).ToList();
List<ScoreBreakdownRow> rows = counted
.Where(q => q.Mode.Name.Length > 0)
.GroupBy(q => (Band: Rounded(q), Mode: q.Mode.Name, Kind: contest.RowKindFor(q)))
.OrderBy(g => BandText(g.Key.Band), StringComparer.Ordinal)
.ThenBy(g => g.Key.Mode, StringComparer.Ordinal)
.ThenBy(g => g.Key.Kind, StringComparer.Ordinal)
.Select(g => Row(g.Key.Band, g.Key.Mode, g.Key.Kind, g))
.ToList();
// the query's total is a group of the same rows, so an empty log has none
if (counted.Count > 0)
{
rows.Add(Row(TotalBand, TotalMode, contest.HasQtcTraffic ? "All" : "", counted));
}
return rows;
}
/// The band as the database stores it: the megahertz label to one decimal.
private static double Rounded(Qso qso) =>
Math.Round(qso.Band?.MegahertzLabel ?? 0, 1, MidpointRounding.AwayFromZero);
/// What SQLite writes for a real number cast to text.
private static string BandText(double band) => band.ToString("0.0", CultureInfo.InvariantCulture);
private static ScoreBreakdownRow Row(double band, string mode, string kind, IEnumerable<Qso> qsos)
{
List<Qso> rows = [.. qsos];
return new ScoreBreakdownRow(
band,
mode,
kind,
rows.Count,
rows.Sum(q => q.Points),
[
rows.Count(q => q.IsMultiplier1),
rows.Count(q => q.IsMultiplier2),
rows.Count(q => q.IsMultiplier3),
]);
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Nonemm.Network;
/// One row of the Broadcast Data tab: whether the data is sent, and to which
/// `address:port` pairs. With no addresses the data goes to the default for its
/// kind.
public sealed record BroadcastChannel
{
public bool IsEnabled { get; init; }
public IReadOnlyList<string> Addresses { get; init; } = [];
}
+14
View File
@@ -0,0 +1,14 @@
namespace Nonemm.Network;
/// The kinds of data N1MM sends to other programs, one row each on its
/// Broadcast Data tab. Contacts have a row there too but go out through
/// `StationNetwork`.
public enum BroadcastKind
{
AppInfo,
Radio,
Spots,
Rotor,
Score,
Lookup,
}
+206
View File
@@ -0,0 +1,206 @@
using System.Globalization;
using System.Text;
using System.Xml.Linq;
namespace Nonemm.Network;
/// What `RadioInfo` says about one radio. Frequencies are in hertz here and go
/// out in the units of ten hertz N1MM uses.
public sealed record RadioReport(
int RadioNumber,
long Frequency,
long TransmitFrequency,
string Mode,
string StationCall,
string OperatorCall,
bool IsRunning,
int FocusRadioNumber,
bool IsSplit,
int ActiveRadioNumber,
bool IsTransmitting,
string RadioName,
bool IsConnected);
/// One spot as `spot` carries it. The frequency is in hertz.
public sealed record SpotReport(
string Call,
long Frequency,
string Spotter,
DateTime AtUtc,
string Mode,
string Comment,
string Status);
/// One row of the score as `dynamicresults` carries it. `Band` is the number
/// N1MM stores for the band, and `Mode` the mode of the contacts as logged;
/// both are turned into the labels of the message when it is written.
public sealed record ScoreReportRow(
double Band,
string Mode,
int Qsos,
IReadOnlyList<int> Multipliers,
int Points);
/// What `dynamicresults` says about the entry.
public sealed record ScoreReport(
string Contest,
string Call,
string Operators,
string Power,
string Assisted,
string Transmitter,
string OperatorCategory,
string Bands,
string ModeCategory,
string Overlay,
string Station,
string Club,
string Country,
int CqZone,
int IaruZone,
string ArrlSection,
string State,
string Grid,
IReadOnlyList<string> MultiplierNames,
IReadOnlyList<ScoreReportRow> Rows,
long Score,
DateTime AtUtc);
/// The messages N1MM sends to other programs besides the contacts: `AppInfo`,
/// `RadioInfo`, `spot` and `dynamicresults`. The element names are N1MM's.
public static class BroadcastMessages
{
private const long FrequencyUnit = 10;
private const string TimeFormat = "yyyy-MM-dd HH:mm:ss";
public static string AppInfo(
string databaseName,
int contestNumber,
string contestName,
string stationName,
string myCall) =>
new XElement(
"AppInfo",
new XElement("app", "Nonemm"),
new XElement("dbname", databaseName),
new XElement("contestnr", contestNumber),
new XElement("contestname", contestName),
new XElement("StationName", stationName),
new XElement("mycall", myCall)).ToString();
/// Fields this program has no value for are sent with the value N1MM sends
/// when it has none, so a reader that expects all of them finds them.
public static string Radio(RadioReport radio, string stationName) =>
new XElement(
"RadioInfo",
new XElement("app", "Nonemm"),
new XElement("StationName", stationName),
new XElement("RadioNr", radio.RadioNumber),
new XElement("Freq", radio.Frequency / FrequencyUnit),
new XElement("TXFreq", radio.TransmitFrequency / FrequencyUnit),
new XElement("Mode", radio.Mode),
new XElement("mycall", radio.StationCall),
new XElement("OpCall", radio.OperatorCall),
new XElement("IsRunning", Flag(radio.IsRunning)),
new XElement("FocusEntry", 0),
new XElement("EntryWindowHwnd", 0),
new XElement("Antenna", 0),
new XElement("Rotors", ""),
new XElement("FocusRadioNr", radio.FocusRadioNumber),
new XElement("IsStereo", Flag(false)),
new XElement("IsSplit", Flag(radio.IsSplit)),
new XElement("ActiveRadioNr", radio.ActiveRadioNumber),
new XElement("IsTransmitting", Flag(radio.IsTransmitting)),
new XElement("FunctionKeyCaption", ""),
new XElement("RadioName", radio.RadioName),
new XElement("AuxAntSelected", 0),
new XElement("AuxAntSelectedName", ""),
new XElement("IsConnected", Flag(radio.IsConnected))).ToString();
/// `action` is `add` or `delete`. N1MM leaves the comment and status off a
/// delete.
public static string Spot(SpotReport spot, string action, string stationName)
{
XElement root = new(
"spot",
new XElement("app", "Nonemm"),
new XElement("StationName", stationName),
new XElement("dxcall", spot.Call),
new XElement("frequency", (spot.Frequency / 1000.0).ToString("0.0", CultureInfo.InvariantCulture)),
new XElement("spottercall", spot.Spotter),
new XElement("timestamp", spot.AtUtc.ToString(TimeFormat, CultureInfo.InvariantCulture)),
new XElement("action", action),
new XElement("mode", spot.Mode));
if (action == "add")
{
root.Add(new XElement("comment", spot.Comment));
root.Add(new XElement("status", spot.Status));
root.Add(new XElement("statuslist", spot.Status));
}
return root.ToString();
}
/// Written the way N1MM writes it, line by line with tabs: the layout and
/// the order of the elements are what a program reading it relies on.
public static string Score(ScoreReport report)
{
StringBuilder text = new("<?xml version=\"1.0\"?>\r\n<dynamicresults>\r\n");
text.Append("\t<contest>").Append(Escape(report.Contest)).Append("</contest>\r\n");
text.Append("\t<call>").Append(Escape(report.Call.Trim())).Append("</call>\r\n");
text.Append("\t<ops>").Append(Escape(report.Operators)).Append("</ops>\r\n");
text.Append("\t<class power=\"").Append(Escape(report.Power));
text.Append("\" assisted = \"").Append(Escape(report.Assisted));
text.Append("\" transmitter=\"").Append(Escape(report.Transmitter));
text.Append("\" ops=\"").Append(Escape(ScoreLabels.OperatorCategory(report.OperatorCategory)));
text.Append("\" bands=\"").Append(Escape(report.Bands));
text.Append("\" mode=\"").Append(Escape(report.ModeCategory));
text.Append("\" overlay=\"").Append(Escape(report.Overlay));
text.Append("\" station=\"").Append(Escape(report.Station));
text.Append("\" ></class>\r\n");
text.Append("\t<club>").Append(Escape(report.Club)).Append("</club>\r\n");
text.Append("\t<qth>\r\n");
text.Append("\t\t<dxcccountry>").Append(Escape(report.Country)).Append("</dxcccountry>\r\n");
text.Append("\t\t<cqzone>").Append(report.CqZone).Append("</cqzone>\r\n");
text.Append("\t\t<iaruzone>").Append(report.IaruZone).Append("</iaruzone>\r\n");
if (report.ArrlSection.Length > 0)
{
text.Append("\t\t<arrlsection>").Append(Escape(report.ArrlSection)).Append("</arrlsection>\r\n");
}
if (report.State.Length > 0)
{
text.Append("\t\t<stprvoth>").Append(Escape(report.State)).Append("</stprvoth>\r\n");
}
if (report.Grid.Length is 4 or 6)
{
text.Append($"\t\t<grid{report.Grid.Length}>").Append(Escape(report.Grid)).Append($"</grid{report.Grid.Length}>\r\n");
}
text.Append("\t</qth>\r\n\t<breakdown>\r\n");
foreach (ScoreReportRow row in report.Rows)
{
string band = ScoreLabels.Band(row.Band);
string mode = ScoreLabels.Mode(row.Mode);
text.Append($"\t\t<qso band=\"{band}\" mode=\"{mode}\">{row.Qsos}</qso>\r\n");
for (int at = 0; at < report.MultiplierNames.Count; at++)
{
string type = Escape(ScoreLabels.MultiplierType(report.MultiplierNames[at]));
int count = at < row.Multipliers.Count ? row.Multipliers[at] : 0;
text.Append($"\t\t<mult band=\"{band}\" mode=\"{mode}\" type=\"{type}\">{count}</mult>\r\n");
}
text.Append($"\t\t<point band=\"{band}\" mode=\"{mode}\">{row.Points}</point>\r\n");
}
text.Append("\t</breakdown>\r\n");
text.Append("\t<score>").Append(report.Score).Append("</score>\r\n");
text.Append("\t<timestamp>")
.Append(report.AtUtc.ToString(TimeFormat, CultureInfo.InvariantCulture))
.Append("</timestamp>\r\n</dynamicresults>\r\n");
return text.ToString();
}
/// N1MM writes these values as they are, so a club called `A&B` makes a
/// message no parser reads. They are escaped here.
private static string Escape(string value) => System.Security.SecurityElement.Escape(value) ?? "";
/// .NET's own text for a boolean, which is what N1MM writes.
private static string Flag(bool value) => value ? "True" : "False";
}
+50
View File
@@ -0,0 +1,50 @@
using System.Net;
namespace Nonemm.Network;
/// Reads the `address:port` lists of the Broadcast Data tab.
public static class BroadcastTargets
{
/// N1MM's default for everything but the score and the rotor.
public const int DefaultPort = 12060;
public const int ScorePort = 12050;
public const int RotorPort = 12040;
public static int DefaultPortFor(BroadcastKind kind) => kind switch
{
BroadcastKind.Score => ScorePort,
BroadcastKind.Rotor => RotorPort,
_ => DefaultPort,
};
/// The local machine on the default port of the kind, which is where N1MM
/// sends when the list is empty.
public static IPEndPoint DefaultFor(BroadcastKind kind) =>
new(IPAddress.Loopback, DefaultPortFor(kind));
/// Splits a list on spaces, commas and line breaks. An entry without a port
/// takes `defaultPort`. Null when an entry is not an address, with the entry
/// in `problem`.
public static IReadOnlyList<IPEndPoint>? Parse(string text, int defaultPort, out string problem)
{
problem = "";
List<IPEndPoint> found = [];
foreach (string entry in text.Split([' ', ',', ';', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
{
string[] parts = entry.Split(':');
string host = parts[0] == "localhost" ? "127.0.0.1" : parts[0];
if (parts.Length > 2
|| !IPAddress.TryParse(host, out IPAddress? address)
|| (parts.Length == 2 && !(int.TryParse(parts[1], out int given) && given is > 0 and < 65536)))
{
problem = $"{entry} is not in the form IP_address:port, such as 192.168.1.2:{defaultPort}";
return null;
}
int port = parts.Length == 2 ? int.Parse(parts[1]) : defaultPort;
found.Add(new IPEndPoint(address, port));
}
return found;
}
}
+5
View File
@@ -33,6 +33,11 @@ public static class ContactMessage
return root.ToString();
}
/// The contact being typed, for a program that looks the call up. N1MM sends
/// the same fields as a logged contact under another name.
public static string WriteLookup(Qso qso, string myCallsign, string stationName) =>
Contact("lookupinfo", qso, myCallsign, stationName).ToString();
public static string WriteDelete(Qso qso, string myCallsign, string stationName) =>
new XElement(
"contactdelete",
+70
View File
@@ -0,0 +1,70 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace Nonemm.Network;
/// Sends the XML messages of the Broadcast Data tab to the programs that read
/// them. Each kind goes to its own list of addresses.
public sealed class DataBroadcaster : IDisposable
{
private readonly Dictionary<BroadcastKind, IReadOnlyList<IPEndPoint>> targets = [];
private readonly UdpClient sender = new() { EnableBroadcast = true };
/// Entries that could not be read are skipped and reported in `Failed`.
public DataBroadcaster(IReadOnlyDictionary<BroadcastKind, BroadcastChannel> channels)
{
foreach ((BroadcastKind kind, BroadcastChannel channel) in channels)
{
if (!channel.IsEnabled)
{
continue;
}
IReadOnlyList<IPEndPoint>? parsed = BroadcastTargets.Parse(
string.Join(' ', channel.Addresses),
BroadcastTargets.DefaultPortFor(kind),
out string problem);
if (parsed is null)
{
Problems.Add($"{kind}: {problem}");
continue;
}
targets[kind] = parsed.Count > 0 ? parsed : [BroadcastTargets.DefaultFor(kind)];
}
}
public List<string> Problems { get; } = [];
public event EventHandler<string>? Failed;
public bool IsSending(BroadcastKind kind) => targets.ContainsKey(kind);
/// Does nothing when the kind is off. The send does not wait, so a slow
/// network cannot hold up the caller.
public void Send(BroadcastKind kind, string xml)
{
if (!targets.TryGetValue(kind, out IReadOnlyList<IPEndPoint>? destinations))
{
return;
}
byte[] message = Encoding.UTF8.GetBytes(xml);
foreach (IPEndPoint destination in destinations)
{
_ = SendToAsync(message, destination);
}
}
private async Task SendToAsync(byte[] message, IPEndPoint destination)
{
try
{
await sender.SendAsync(message, destination).ConfigureAwait(false);
}
catch (Exception e) when (e is SocketException or ObjectDisposedException)
{
Failed?.Invoke(this, $"could not send to {destination}: {e.Message}");
}
}
public void Dispose() => sender.Dispose();
}
+100
View File
@@ -0,0 +1,100 @@
using System.Globalization;
namespace Nonemm.Network;
/// The labels `dynamicresults` gives bands, modes and multiplier types. They
/// are not the labels of the log: N1MM names a band by its wavelength, folds
/// the modes into a few, and renames three multiplier types.
public static class ScoreLabels
{
/// The band N1MM stores for the total row.
public const double TotalBand = 900_000_000;
/// `Band` is the megahertz label N1MM stores for the band, or 900000000 for
/// the total row. Wavelengths come out in metres: 80, 40, 0.7 for 70 cm.
public static string Band(double band)
{
if (band == TotalBand)
{
return "total";
}
if (band is > 225 and < 24251)
{
return (Meters(band) / 100.0).ToString(CultureInfo.InvariantCulture);
}
if (band is > 24251 and < 250001)
{
return // N1MM prints a float with seven digits; the shortest text that reads
// back as the same float would be 0.0064000003
(Millimeters(band) / 1000f).ToString("G7", CultureInfo.InvariantCulture);
}
if (band is > 221 and < 226)
{
return "1.25";
}
return Meters(band).ToString(CultureInfo.InvariantCulture);
}
/// N1MM works the band out from the whole megahertz of the label, as
/// `BandFor` does, so 3.5 is 3 and counts as 80 metres.
private static int Meters(double band) => ((long)Math.Round(band * 1000) / 1000) switch
{
1 or 2 => 160,
3 or 4 => 80,
5 => 60,
7 => 40,
10 => 30,
14 => 20,
18 => 17,
21 => 15,
24 => 12,
28 or 29 => 10,
>= 50 and <= 54 => 6,
70 => 4,
>= 144 and <= 148 => 2,
>= 420 and <= 450 => 70,
>= 902 and <= 928 => 33,
>= 1240 and <= 1300 => 23,
>= 2300 and <= 2450 => 13,
>= 3300 and <= 3500 => 9,
>= 5650 and <= 5925 => 6,
>= 10000 and <= 10500 => 3,
>= 24000 and <= 24250 => 1,
_ => 0,
};
private static float Millimeters(double band) => ((long)Math.Round(band * 1000) / 1000) switch
{
>= 47000 and <= 47200 => 6.4f,
>= 75500 and <= 81000 => 4f,
>= 122250 and <= 123000 => 2.5f,
>= 134000 and <= 141000 => 2f,
>= 241000 and <= 250000 => 1.2f,
_ => 0f,
};
/// The mode of a row. Sideband, AM and FM are all `PH`, and CW and its
/// reverse are `CW`. A mode N1MM does not list, and the total row's `Both`,
/// are `ALL`.
public static string Mode(string mode) => mode switch
{
"SSB" or "AM" or "FM" or "USB" or "LSB" or "WFM" => "PH",
"CW" or "RCW" => "CW",
"RTTY" or "RTTY-R" => "RTTY",
"PSK31" or "PSK61" or "FT8" or "FT4" or "FT2" or "JTTY" => mode,
_ => "ALL",
};
/// The type of a multiplier column, from the name the contest gives it.
public static string MultiplierType(string name) => name.ToUpperInvariant() switch
{
"COUNTRYPREFIX" => "country",
"SECT" => "state",
"ZN" => "zone",
string other => other,
};
/// An assisted single operator is reported as plain single operator.
public static string OperatorCategory(string category) =>
category == "SINGLE-OP-ASSISTED" ? "SINGLE-OP" : category;
}
+5
View File
@@ -43,6 +43,11 @@ public sealed class StationNetwork : IDisposable
public Task SendAsync(Qso qso, string myCallsign, CancellationToken cancellation = default) =>
SendTextAsync(ContactMessage.Write(qso, myCallsign, stationName), cancellation);
/// A contact another computer of the entry logged, passed on to the other
/// programs. It keeps the name of the computer that logged it.
public Task SendForwardedAsync(Qso qso, string myCallsign, CancellationToken cancellation = default) =>
SendTextAsync(ContactMessage.Write(qso, myCallsign, qso.StationName), cancellation);
public Task SendEditAsync(
Qso qso,
string myCallsign,