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:
@@ -1030,11 +1030,28 @@ at the node as well, because that is less traffic on the wire.
|
||||
|
||||
### Networked stations
|
||||
|
||||
**Config → Network** names this station and lists the others. Each contact is
|
||||
sent to them as it is logged, in N1MM's `contactinfo` message, so an N1MM
|
||||
station on the same network sees them too. Editing a contact sends
|
||||
`contactreplace` and deleting one sends `contactdelete`, the same as N1MM. With
|
||||
no addresses listed the messages are broadcast. A contact that arrives is scored
|
||||
**Config → Edit Networked-Computer Names** names this station and sets the
|
||||
port it listens on. **Config → Configure Broadcast Data** is N1MM's Broadcast
|
||||
Data tab: seven kinds of data, each with an on/off box and a list of
|
||||
`address:port` pairs separated by spaces. An empty list sends to `127.0.0.1` on
|
||||
N1MM's port for that kind: 12060, except the score on 12050 and the rotor on
|
||||
12040.
|
||||
|
||||
| Row | What goes out | When |
|
||||
| --- | --- | --- |
|
||||
| Application Info | `AppInfo`: database, contest number and name | a contest is opened |
|
||||
| Radio | `RadioInfo`: frequency, mode, run/search, split, connection | something in it changes |
|
||||
| Contacts | `contactinfo`, `contactreplace`, `contactdelete` | a contact is logged, edited or deleted |
|
||||
| Contacts, All Computers | the contacts the other logging computers make | one arrives on port 12070 |
|
||||
| Spots | `spot` with N1MM's status (`new qso`, `dupe`, `single mult`, `double mult`) | a spot reaches the bandmap |
|
||||
| Rotor | nothing yet: this program has no rotor control | |
|
||||
| Score | `dynamicresults`: a row per band and mode, and a total, in N1MM's layout | every ten seconds |
|
||||
| External Callsign Lookup | `lookupinfo`: the contact being typed | Tab leaves the callsign box on a new call |
|
||||
|
||||
Contacts with no address listed are broadcast to the whole network, and each
|
||||
contact is sent in N1MM's `contactinfo` message, so an N1MM station on the same
|
||||
network sees them too. Editing a contact sends `contactreplace` and deleting
|
||||
one sends `contactdelete`, the same as N1MM. A contact that arrives is scored
|
||||
again here from the rules rather than trusted.
|
||||
|
||||
An incoming edit or delete is matched by contact id first. N1MM does not know
|
||||
|
||||
@@ -738,6 +738,29 @@ legend. It has no tooltips, no zoom and no 3-D, all of which N1MM's chart
|
||||
control gives it for free. A column label that will not fit under its column is
|
||||
turned on its side, and one that will not fit even then is left out.
|
||||
|
||||
## Broadcast data
|
||||
|
||||
What the Broadcast Data dialog saves but does not send yet, or sends in less
|
||||
detail than N1MM:
|
||||
|
||||
- **Rotor.** The row is saved. Nothing sends `N1MMRotor` messages, because this
|
||||
program has no rotor control.
|
||||
- **Spots.** Only `add` goes out. N1MM also sends `delete` when a spot leaves the
|
||||
bandmap. Spots that expire are not reported.
|
||||
- **Score.** The rows, their order, the band and mode labels and the layout
|
||||
follow N1MM's query and writer. The row set and order were checked against the
|
||||
query on `om5m.s3db` for 62 contests. Points and multiplier flags come from our
|
||||
own scoring, so they carry the differences listed in `n1mm-interop.md`. The
|
||||
multiplier types come from a table of N1MM's names (`N1mmMultiplierNames`)
|
||||
for the built-in contests; a user-defined contest uses the names its rules
|
||||
give. N1MM writes text values unescaped and we escape them. The bands' wavelength
|
||||
labels were read from N1MM's code and not compared with its output.
|
||||
- **Radio.** `RadioName`, `Antenna`, `Rotors`, `FocusEntry`, `EntryWindowHwnd`
|
||||
and the aux antenna fields are sent as 0 or empty, and `IsTransmitting` is
|
||||
always `False`.
|
||||
- **Contacts, All Computers.** Only logged contacts are passed on, not edits or
|
||||
deletes.
|
||||
|
||||
## Known rough edges
|
||||
|
||||
**The band plan covers 160M to 2M only.** 60M and everything above 2M get no
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using Nonemm.Contests.Rules;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Tests;
|
||||
|
||||
public class ScoreBreakdownTests
|
||||
{
|
||||
private static readonly Contest Contest = new CqWorldWide(ModeCategory.Cw);
|
||||
|
||||
private static Qso Scored(string call, double kilohertz, Mode mode, int points, bool zone = false, bool country = false) =>
|
||||
TestLog.Contact(call, kilohertz, mode) with
|
||||
{
|
||||
Points = points,
|
||||
IsMultiplier1 = zone,
|
||||
IsMultiplier2 = country,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Rows_are_ordered_by_the_band_as_text_then_by_mode_and_the_total_is_last()
|
||||
{
|
||||
List<Qso> log =
|
||||
[
|
||||
Scored("DL1AAA", 3_525, Modes.Cw, 1),
|
||||
Scored("DL1BBB", 10_120, Modes.Cw, 1),
|
||||
Scored("DL1CCC", 14_025, Modes.Cw, 1),
|
||||
Scored("DL1DDD", 14_200, Modes.Usb, 1),
|
||||
Scored("DL1EEE", 1_830, Modes.Cw, 1),
|
||||
];
|
||||
|
||||
IReadOnlyList<ScoreBreakdownRow> rows = ScoreBreakdown.For(log, Contest);
|
||||
|
||||
Assert.Equal(
|
||||
[(1.8, "CW"), (10.0, "CW"), (14.0, "CW"), (14.0, "USB"), (3.5, "CW"), (ScoreBreakdown.TotalBand, "Both")],
|
||||
rows.Select(r => (r.Band, r.Mode)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_row_adds_up_contacts_points_and_multiplier_flags()
|
||||
{
|
||||
List<Qso> log =
|
||||
[
|
||||
Scored("DL1AAA", 14_025, Modes.Cw, 3, zone: true, country: true),
|
||||
Scored("DL1BBB", 14_030, Modes.Cw, 3, country: true),
|
||||
Scored("DL1CCC", 7_010, Modes.Cw, 3, zone: true),
|
||||
];
|
||||
|
||||
ScoreBreakdownRow twenty = ScoreBreakdown.For(log, Contest).Single(r => r.Band == 14.0);
|
||||
ScoreBreakdownRow total = ScoreBreakdown.For(log, Contest).Last();
|
||||
|
||||
Assert.Equal((2, 6, 1, 2, 0), (twenty.Qsos, twenty.Points, twenty.Multipliers[0], twenty.Multipliers[1], twenty.Multipliers[2]));
|
||||
Assert.Equal((3, 9, 2, 2), (total.Qsos, total.Points, total.Multipliers[0], total.Multipliers[1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_dupe_and_an_unclaimed_contact_are_not_counted()
|
||||
{
|
||||
List<Qso> log =
|
||||
[
|
||||
Scored("DL1AAA", 14_025, Modes.Cw, 3),
|
||||
Scored("DL1AAA", 14_026, Modes.Cw, 0) with { ContactType = "D" },
|
||||
Scored("DL1BBB", 14_027, Modes.Cw, 3) with { IsClaimed = false },
|
||||
];
|
||||
|
||||
Assert.Equal(1, ScoreBreakdown.For(log, Contest).Last().Qsos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_log_has_no_rows_not_even_a_total()
|
||||
{
|
||||
Assert.Empty(ScoreBreakdown.For([], Contest));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace Nonemm.Network.Tests;
|
||||
|
||||
public class BroadcastTests
|
||||
{
|
||||
[Fact]
|
||||
public void An_address_without_a_port_takes_the_default_port()
|
||||
{
|
||||
IReadOnlyList<IPEndPoint>? parsed = BroadcastTargets.Parse("127.0.0.1 192.168.1.255:9000", 12060, out _);
|
||||
|
||||
Assert.Equal([new IPEndPoint(IPAddress.Loopback, 12060), new IPEndPoint(IPAddress.Parse("192.168.1.255"), 9000)], parsed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-address")]
|
||||
[InlineData("127.0.0.1:99999")]
|
||||
[InlineData("127.0.0.1:12060:1")]
|
||||
public void An_entry_that_is_not_an_address_is_refused(string text)
|
||||
{
|
||||
Assert.Null(BroadcastTargets.Parse(text, 12060, out string problem));
|
||||
Assert.Contains(text, problem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_score_and_the_rotor_have_their_own_default_ports()
|
||||
{
|
||||
Assert.Equal(12050, BroadcastTargets.DefaultFor(BroadcastKind.Score).Port);
|
||||
Assert.Equal(12040, BroadcastTargets.DefaultFor(BroadcastKind.Rotor).Port);
|
||||
Assert.Equal(12060, BroadcastTargets.DefaultFor(BroadcastKind.Radio).Port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Radio_info_carries_the_frequency_in_units_of_ten_hertz()
|
||||
{
|
||||
string xml = BroadcastMessages.Radio(
|
||||
new RadioReport(1, 14_025_500, 14_025_500, "CW", "OK1ABC", "OK1ABC", true, 1, false, 1, false, "", true),
|
||||
"RUN-PC");
|
||||
|
||||
XElement root = XElement.Parse(xml);
|
||||
Assert.Equal("RadioInfo", root.Name.LocalName);
|
||||
Assert.Equal("1402550", root.Element("Freq")!.Value);
|
||||
Assert.Equal("True", root.Element("IsRunning")!.Value);
|
||||
Assert.Equal("RUN-PC", root.Element("StationName")!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_deleted_spot_carries_no_comment_or_status()
|
||||
{
|
||||
SpotReport spot = new("DL1ABC", 7_012_300, "OK2XYZ", new DateTime(2026, 5, 30, 12, 0, 0), "CW", "up 1", "new qso");
|
||||
|
||||
XElement added = XElement.Parse(BroadcastMessages.Spot(spot, "add", "RUN-PC"));
|
||||
XElement deleted = XElement.Parse(BroadcastMessages.Spot(spot, "delete", "RUN-PC"));
|
||||
|
||||
Assert.Equal("7012.3", added.Element("frequency")!.Value);
|
||||
Assert.Equal("new qso", added.Element("status")!.Value);
|
||||
Assert.Null(deleted.Element("status"));
|
||||
Assert.Null(deleted.Element("comment"));
|
||||
}
|
||||
|
||||
private static ScoreReport Score(string state = "", string grid = "JO70", string assisted = "NON-ASSISTED") => new(
|
||||
"CQ-WW-CW", "OK1ABC ", "OK1ABC", "HIGH", assisted, "ONE", "SINGLE-OP-ASSISTED", "ALL", "CW", "", "",
|
||||
"", "OK", 15, 28, "", state, grid,
|
||||
["ZN", "COUNTRYPREFIX"],
|
||||
[
|
||||
new ScoreReportRow(3.5, "CW", 7, [2, 3], 18),
|
||||
new ScoreReportRow(ScoreLabels.TotalBand, "Both", 10, [3, 4], 25),
|
||||
],
|
||||
175, new DateTime(2026, 5, 30, 12, 0, 0));
|
||||
|
||||
[Fact]
|
||||
public void The_score_is_written_in_the_layout_N1MM_uses()
|
||||
{
|
||||
string xml = BroadcastMessages.Score(Score(state: "OK"));
|
||||
|
||||
Assert.Equal(
|
||||
"<?xml version=\"1.0\"?>\r\n<dynamicresults>\r\n"
|
||||
+ "\t<contest>CQ-WW-CW</contest>\r\n"
|
||||
+ "\t<call>OK1ABC</call>\r\n"
|
||||
+ "\t<ops>OK1ABC</ops>\r\n"
|
||||
+ "\t<class power=\"HIGH\" assisted = \"NON-ASSISTED\" transmitter=\"ONE\" ops=\"SINGLE-OP\" bands=\"ALL\" mode=\"CW\" overlay=\"\" station=\"\" ></class>\r\n"
|
||||
+ "\t<club></club>\r\n"
|
||||
+ "\t<qth>\r\n"
|
||||
+ "\t\t<dxcccountry>OK</dxcccountry>\r\n"
|
||||
+ "\t\t<cqzone>15</cqzone>\r\n"
|
||||
+ "\t\t<iaruzone>28</iaruzone>\r\n"
|
||||
+ "\t\t<stprvoth>OK</stprvoth>\r\n"
|
||||
+ "\t\t<grid4>JO70</grid4>\r\n"
|
||||
+ "\t</qth>\r\n"
|
||||
+ "\t<breakdown>\r\n"
|
||||
+ "\t\t<qso band=\"80\" mode=\"CW\">7</qso>\r\n"
|
||||
+ "\t\t<mult band=\"80\" mode=\"CW\" type=\"zone\">2</mult>\r\n"
|
||||
+ "\t\t<mult band=\"80\" mode=\"CW\" type=\"country\">3</mult>\r\n"
|
||||
+ "\t\t<point band=\"80\" mode=\"CW\">18</point>\r\n"
|
||||
+ "\t\t<qso band=\"total\" mode=\"ALL\">10</qso>\r\n"
|
||||
+ "\t\t<mult band=\"total\" mode=\"ALL\" type=\"zone\">3</mult>\r\n"
|
||||
+ "\t\t<mult band=\"total\" mode=\"ALL\" type=\"country\">4</mult>\r\n"
|
||||
+ "\t\t<point band=\"total\" mode=\"ALL\">25</point>\r\n"
|
||||
+ "\t</breakdown>\r\n"
|
||||
+ "\t<score>175</score>\r\n"
|
||||
+ "\t<timestamp>2026-05-30 12:00:00</timestamp>\r\n"
|
||||
+ "</dynamicresults>\r\n",
|
||||
xml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_score_leaves_out_an_empty_state_and_a_grid_that_is_not_four_or_six_letters()
|
||||
{
|
||||
string xml = BroadcastMessages.Score(Score(grid: "JO"));
|
||||
|
||||
Assert.DoesNotContain("stprvoth", xml);
|
||||
Assert.DoesNotContain("grid", xml);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1.8, "160")]
|
||||
[InlineData(3.5, "80")]
|
||||
[InlineData(5, "60")]
|
||||
[InlineData(7, "40")]
|
||||
[InlineData(10, "30")]
|
||||
[InlineData(14, "20")]
|
||||
[InlineData(18, "17")]
|
||||
[InlineData(21, "15")]
|
||||
[InlineData(24, "12")]
|
||||
[InlineData(28, "10")]
|
||||
[InlineData(50, "6")]
|
||||
[InlineData(70, "4")]
|
||||
[InlineData(144, "2")]
|
||||
[InlineData(222, "1.25")]
|
||||
[InlineData(420, "0.7")]
|
||||
[InlineData(902, "0.33")]
|
||||
[InlineData(1240, "0.23")]
|
||||
[InlineData(2300, "0.13")]
|
||||
[InlineData(3300, "0.09")]
|
||||
[InlineData(5650, "0.06")]
|
||||
[InlineData(10000, "0.03")]
|
||||
[InlineData(24000, "0.01")]
|
||||
[InlineData(47000, "0.0064")]
|
||||
[InlineData(76000, "0.004")]
|
||||
[InlineData(122250, "0.0025")]
|
||||
[InlineData(134000, "0.002")]
|
||||
[InlineData(241000, "0.0012")]
|
||||
[InlineData(0.1, "0")]
|
||||
[InlineData(0.5, "0")]
|
||||
[InlineData(ScoreLabels.TotalBand, "total")]
|
||||
public void A_band_is_named_by_its_wavelength_in_metres(double band, string label)
|
||||
{
|
||||
Assert.Equal(label, ScoreLabels.Band(band));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("USB", "PH")]
|
||||
[InlineData("LSB", "PH")]
|
||||
[InlineData("SSB", "PH")]
|
||||
[InlineData("FM", "PH")]
|
||||
[InlineData("CW", "CW")]
|
||||
[InlineData("RCW", "CW")]
|
||||
[InlineData("RTTY", "RTTY")]
|
||||
[InlineData("RTTY-R", "RTTY")]
|
||||
[InlineData("FT8", "FT8")]
|
||||
[InlineData("PSK31", "PSK31")]
|
||||
[InlineData("JT65", "ALL")]
|
||||
[InlineData("Both", "ALL")]
|
||||
public void A_mode_is_folded_into_the_few_N1MM_names(string mode, string label)
|
||||
{
|
||||
Assert.Equal(label, ScoreLabels.Mode(mode));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CountryPrefix", "country")]
|
||||
[InlineData("Countryprefix", "country")]
|
||||
[InlineData("Sect", "state")]
|
||||
[InlineData("ZN", "zone")]
|
||||
[InlineData("WPXPrefix", "WPXPREFIX")]
|
||||
public void Three_multiplier_types_are_renamed_and_the_rest_are_upper_case(string name, string type)
|
||||
{
|
||||
Assert.Equal(type, ScoreLabels.MultiplierType(name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_kind_that_is_on_reaches_its_address_and_a_kind_that_is_off_does_not()
|
||||
{
|
||||
using UdpClient receiver = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
int port = ((IPEndPoint)receiver.Client.LocalEndPoint!).Port;
|
||||
using DataBroadcaster broadcaster = new(new Dictionary<BroadcastKind, BroadcastChannel>
|
||||
{
|
||||
[BroadcastKind.Radio] = new() { IsEnabled = true, Addresses = [$"127.0.0.1:{port}"] },
|
||||
[BroadcastKind.Spots] = new() { Addresses = [$"127.0.0.1:{port}"] },
|
||||
});
|
||||
|
||||
broadcaster.Send(BroadcastKind.Spots, "<spot/>");
|
||||
broadcaster.Send(BroadcastKind.Radio, "<RadioInfo/>");
|
||||
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
UdpReceiveResult received = await receiver.ReceiveAsync(timeout.Token);
|
||||
Assert.Equal("<RadioInfo/>", Encoding.UTF8.GetString(received.Buffer));
|
||||
Assert.False(broadcaster.IsSending(BroadcastKind.Spots));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_row_with_an_unreadable_address_is_left_off_and_reported()
|
||||
{
|
||||
using DataBroadcaster broadcaster = new(new Dictionary<BroadcastKind, BroadcastChannel>
|
||||
{
|
||||
[BroadcastKind.Score] = new() { IsEnabled = true, Addresses = ["nowhere"] },
|
||||
});
|
||||
|
||||
Assert.False(broadcaster.IsSending(BroadcastKind.Score));
|
||||
Assert.Single(broadcaster.Problems);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user