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
+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";
}