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