Listen for Radio broadcast data from another Nonemm or N1MM computer and record its frequency, mode and split as this computer's own radio. No radio is controlled. The settings are in the Configure Radios dialog. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
249 lines
10 KiB
C#
249 lines
10 KiB
C#
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();
|
|
|
|
/// Null for anything that is not a `RadioInfo` message with a frequency, so
|
|
/// the other data on the same port is passed over.
|
|
public static RadioBroadcast? ReadRadio(string xml)
|
|
{
|
|
XElement root;
|
|
try
|
|
{
|
|
root = XElement.Parse(xml);
|
|
}
|
|
catch (System.Xml.XmlException)
|
|
{
|
|
return null;
|
|
}
|
|
if (root.Name.LocalName != "RadioInfo"
|
|
|| !long.TryParse(root.Element("Freq")?.Value, CultureInfo.InvariantCulture, out long frequency)
|
|
|| !long.TryParse(root.Element("TXFreq")?.Value, CultureInfo.InvariantCulture, out long transmit)
|
|
|| !int.TryParse(root.Element("RadioNr")?.Value, CultureInfo.InvariantCulture, out int number))
|
|
{
|
|
return null;
|
|
}
|
|
string Text(string name) => root.Element(name)?.Value ?? "";
|
|
bool Yes(string name) => bool.TryParse(Text(name), out bool value) && value;
|
|
int.TryParse(Text("FocusRadioNr"), CultureInfo.InvariantCulture, out int focus);
|
|
int.TryParse(Text("ActiveRadioNr"), CultureInfo.InvariantCulture, out int active);
|
|
return new RadioBroadcast(
|
|
Text("StationName"),
|
|
new RadioReport(
|
|
number,
|
|
frequency * FrequencyUnit,
|
|
transmit * FrequencyUnit,
|
|
Text("Mode"),
|
|
Text("mycall"),
|
|
Text("OpCall"),
|
|
Yes("IsRunning"),
|
|
focus,
|
|
Yes("IsSplit"),
|
|
active,
|
|
Yes("IsTransmitting"),
|
|
Text("RadioName"),
|
|
Yes("IsConnected")));
|
|
}
|
|
|
|
/// `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";
|
|
}
|