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:
Erik
2026-08-27 10:56:35 +00:00
parent ffeeb2cdc1
commit 2834f63c8e
45 changed files with 2667 additions and 0 deletions

View 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;
}

View File

@@ -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>

View 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);
}
}