Add the Avalonia application and the station network
The entry window types contacts and colours them as they are typed; the log, check, bandmap, score and packet windows read the same session. Contacts are shared with the other stations of a multi-operator entry in N1MM's contact message, so an N1MM station on the same network sees them too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
158
src/Nonemm.Network/ContactMessage.cs
Normal file
158
src/Nonemm.Network/ContactMessage.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System.Globalization;
|
||||
using System.Xml.Linq;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Network;
|
||||
|
||||
/// One contact as N1MM broadcasts it: a `contactinfo` XML document. Writing
|
||||
/// N1MM's own message means a Nonemm station and an N1MM station can sit on the
|
||||
/// same network and see each other's contacts.
|
||||
public static class ContactMessage
|
||||
{
|
||||
/// N1MM sends the frequencies in units of ten hertz.
|
||||
private const long FrequencyUnit = 10;
|
||||
|
||||
public static string Write(Qso qso, string myCallsign, string stationName)
|
||||
{
|
||||
XElement root = new(
|
||||
"contactinfo",
|
||||
new XElement("app", "Nonemm"),
|
||||
new XElement("contestname", qso.ContestName),
|
||||
new XElement("contestnr", qso.ContestNumber),
|
||||
new XElement("timestamp", qso.TimestampUtc.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)),
|
||||
new XElement("mycall", myCallsign),
|
||||
new XElement("band", qso.Band?.MegahertzLabel ?? 0),
|
||||
new XElement("rxfreq", qso.Frequency.Hertz / FrequencyUnit),
|
||||
new XElement("txfreq", (qso.QsxFrequency.Hertz == 0 ? qso.Frequency.Hertz : qso.QsxFrequency.Hertz) / FrequencyUnit),
|
||||
new XElement("operator", qso.Operator),
|
||||
new XElement("mode", qso.Mode.Name),
|
||||
new XElement("call", qso.Call.Text),
|
||||
new XElement("countryprefix", qso.CountryPrefix),
|
||||
new XElement("wpxprefix", qso.WpxPrefix),
|
||||
new XElement("stationprefix", qso.StationPrefix),
|
||||
new XElement("continent", qso.Continent),
|
||||
new XElement("snt", qso.SentReport),
|
||||
new XElement("sntnr", qso.SentNumber),
|
||||
new XElement("rcv", qso.ReceivedReport),
|
||||
new XElement("rcvnr", qso.ReceivedNumber),
|
||||
new XElement("gridsquare", qso.GridSquare),
|
||||
new XElement("exchange1", qso.Exchange1),
|
||||
new XElement("section", qso.Section),
|
||||
new XElement("comment", qso.Comment),
|
||||
new XElement("qth", qso.Qth),
|
||||
new XElement("name", qso.Name),
|
||||
new XElement("power", qso.Power),
|
||||
new XElement("misctext", qso.MiscText),
|
||||
new XElement("zone", qso.Zone),
|
||||
new XElement("prec", qso.Precedence),
|
||||
new XElement("ck", qso.Check),
|
||||
new XElement("ismultiplier1", qso.IsMultiplier1 ? 1 : 0),
|
||||
new XElement("ismultiplier2", qso.IsMultiplier2 ? 1 : 0),
|
||||
new XElement("ismultiplier3", qso.IsMultiplier3 ? 1 : 0),
|
||||
new XElement("points", qso.Points),
|
||||
new XElement("radionr", qso.RadioNumber),
|
||||
new XElement("RoverLocation", qso.RoverLocation),
|
||||
new XElement("RadioInterfaced", qso.IsRadioInterfaced ? 1 : 0),
|
||||
new XElement("NetworkedCompNr", qso.NetworkedComputerNumber),
|
||||
new XElement("IsOriginal", qso.IsOriginal),
|
||||
new XElement("NetBiosName", stationName),
|
||||
new XElement("IsRunQSO", qso.IsRunQso ? 1 : 0),
|
||||
new XElement("StationName", stationName),
|
||||
new XElement("ID", qso.Id),
|
||||
new XElement("IsClaimedQso", qso.IsClaimed ? 1 : 0));
|
||||
return root.ToString();
|
||||
}
|
||||
|
||||
/// Null for a message that is not a contact, which is how the other N1MM
|
||||
/// message kinds on the same port are passed over.
|
||||
public static Qso? Read(string xml)
|
||||
{
|
||||
XElement root;
|
||||
try
|
||||
{
|
||||
root = XElement.Parse(xml);
|
||||
}
|
||||
catch (System.Xml.XmlException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (root.Name.LocalName != "contactinfo")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string call = Text(root, "call");
|
||||
if (call.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new Qso
|
||||
{
|
||||
Id = Text(root, "ID") is { Length: > 0 } id ? id : Qso.NewId(),
|
||||
TimestampUtc = Timestamp(root),
|
||||
Call = Callsign.Parse(call),
|
||||
Frequency = Frequency.FromHertz(Integer(root, "rxfreq") * FrequencyUnit),
|
||||
QsxFrequency = Frequency.FromHertz(Integer(root, "txfreq") * FrequencyUnit),
|
||||
Mode = Modes.Parse(Text(root, "mode")) ?? Modes.Cw,
|
||||
ContestName = Text(root, "contestname"),
|
||||
ContestNumber = (int)Integer(root, "contestnr"),
|
||||
SentReport = Text(root, "snt"),
|
||||
ReceivedReport = Text(root, "rcv"),
|
||||
SentNumber = (int)Integer(root, "sntnr"),
|
||||
ReceivedNumber = (int)Integer(root, "rcvnr"),
|
||||
Zone = (int)Integer(root, "zone"),
|
||||
Check = (int)Integer(root, "ck"),
|
||||
Precedence = Text(root, "prec"),
|
||||
Section = Text(root, "section"),
|
||||
Exchange1 = Text(root, "exchange1"),
|
||||
MiscText = Text(root, "misctext"),
|
||||
Comment = Text(root, "comment"),
|
||||
Name = Text(root, "name"),
|
||||
Qth = Text(root, "qth"),
|
||||
Power = Text(root, "power"),
|
||||
GridSquare = Text(root, "gridsquare"),
|
||||
RoverLocation = Text(root, "RoverLocation"),
|
||||
CountryPrefix = Text(root, "countryprefix"),
|
||||
StationPrefix = Text(root, "stationprefix"),
|
||||
WpxPrefix = Text(root, "wpxprefix"),
|
||||
Continent = Text(root, "continent"),
|
||||
Points = (int)Integer(root, "points"),
|
||||
IsMultiplier1 = Flag(root, "ismultiplier1"),
|
||||
IsMultiplier2 = Flag(root, "ismultiplier2"),
|
||||
IsMultiplier3 = Flag(root, "ismultiplier3"),
|
||||
IsRunQso = Flag(root, "IsRunQSO"),
|
||||
Operator = Text(root, "operator"),
|
||||
RadioNumber = (int)Integer(root, "radionr"),
|
||||
IsRadioInterfaced = Flag(root, "RadioInterfaced"),
|
||||
NetworkedComputerNumber = (int)Integer(root, "NetworkedCompNr"),
|
||||
StationName = Text(root, "StationName") is { Length: > 0 } station
|
||||
? station
|
||||
: Text(root, "NetBiosName"),
|
||||
// a contact that arrived over the network was made somewhere else
|
||||
IsOriginal = false,
|
||||
IsClaimed = Flag(root, "IsClaimedQso"),
|
||||
};
|
||||
}
|
||||
|
||||
private static string Text(XElement root, string name) =>
|
||||
root.Element(name)?.Value.Trim() ?? "";
|
||||
|
||||
private static long Integer(XElement root, string name) =>
|
||||
long.TryParse(Text(root, name), NumberStyles.Integer, CultureInfo.InvariantCulture, out long value)
|
||||
? value
|
||||
: 0;
|
||||
|
||||
private static bool Flag(XElement root, string name)
|
||||
{
|
||||
string text = Text(root, name);
|
||||
return text.Equals("true", StringComparison.OrdinalIgnoreCase) || text == "1";
|
||||
}
|
||||
|
||||
private static DateTime Timestamp(XElement root) =>
|
||||
DateTime.TryParse(
|
||||
Text(root, "timestamp"),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out DateTime when)
|
||||
? when
|
||||
: DateTime.UtcNow;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
105
src/Nonemm.Network/StationNetwork.cs
Normal file
105
src/Nonemm.Network/StationNetwork.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Network;
|
||||
|
||||
/// Keeps the stations of a multi-operator entry in step. Each contact is sent
|
||||
/// to every peer as it is logged, and contacts that arrive from a peer are
|
||||
/// handed to whoever owns the log.
|
||||
public sealed class StationNetwork : IDisposable
|
||||
{
|
||||
private readonly int port;
|
||||
private readonly string stationName;
|
||||
private readonly IReadOnlyList<IPEndPoint> peers;
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
private readonly UdpClient listener;
|
||||
private readonly UdpClient sender = new();
|
||||
private Task? loop;
|
||||
|
||||
public StationNetwork(int port, string stationName, IEnumerable<string> peerAddresses)
|
||||
{
|
||||
this.port = port;
|
||||
this.stationName = stationName;
|
||||
peers = peerAddresses.Select(a => Endpoint(a, port)).OfType<IPEndPoint>().ToList();
|
||||
listener = new UdpClient(new IPEndPoint(IPAddress.Any, port));
|
||||
sender.EnableBroadcast = true;
|
||||
}
|
||||
|
||||
public string StationName => stationName;
|
||||
|
||||
public IReadOnlyList<IPEndPoint> Peers => peers;
|
||||
|
||||
/// A contact another station logged. It arrives already scored; the log
|
||||
/// works its points and multipliers out again from the rules.
|
||||
public event EventHandler<Qso>? ContactArrived;
|
||||
|
||||
public event EventHandler<string>? Failed;
|
||||
|
||||
public void Start() => loop ??= Task.Run(() => ListenAsync(stopping.Token));
|
||||
|
||||
public async Task SendAsync(Qso qso, string myCallsign, CancellationToken cancellation = default)
|
||||
{
|
||||
byte[] message = Encoding.UTF8.GetBytes(ContactMessage.Write(qso, myCallsign, stationName));
|
||||
foreach (IPEndPoint peer in Destinations())
|
||||
{
|
||||
try
|
||||
{
|
||||
await sender.SendAsync(message, peer, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Failed?.Invoke(this, $"could not reach {peer}: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
stopping.Cancel();
|
||||
listener.Dispose();
|
||||
sender.Dispose();
|
||||
stopping.Dispose();
|
||||
}
|
||||
|
||||
/// With no peers named, the contact goes out as a broadcast, which is how a
|
||||
/// station that has just joined is found without configuring anything.
|
||||
private IEnumerable<IPEndPoint> Destinations() =>
|
||||
peers.Count > 0 ? peers : [new IPEndPoint(IPAddress.Broadcast, port)];
|
||||
|
||||
private async Task ListenAsync(CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
UdpReceiveResult received = await listener.ReceiveAsync(cancellation).ConfigureAwait(false);
|
||||
Qso? qso = ContactMessage.Read(Encoding.UTF8.GetString(received.Buffer));
|
||||
if (qso is not null && qso.StationName != stationName)
|
||||
{
|
||||
ContactArrived?.Invoke(this, qso);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Failed?.Invoke(this, e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IPEndPoint? Endpoint(string address, int defaultPort)
|
||||
{
|
||||
string[] parts = address.Split(':');
|
||||
if (!IPAddress.TryParse(parts[0].Trim(), out IPAddress? host))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
int port = parts.Length > 1 && int.TryParse(parts[1], out int given) ? given : defaultPort;
|
||||
return new IPEndPoint(host, port);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user