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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user