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
+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,