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>
106 lines
3.5 KiB
C#
106 lines
3.5 KiB
C#
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);
|
|
}
|
|
}
|