Double-click a cell in the log window to change it. The columns follow the contest exchange instead of being fixed in the XAML, so CQ WW gets a Zone column and Sweepstakes gets Nr, Prec, Ck and Sec. QsoEditor holds the column list and applies one field edit. Validation comes from the ExchangeFieldKind the contest declared: a CQ zone is 1 to 40, an ITU zone 1 to 90, a section is looked up in the ARRL list, a grid must parse. A refused edit returns the reason and leaves the log alone, so the cell reverts. Changing the callsign runs the country lookup again. Delete, or the right-click menu, removes the selected contact after a confirmation. Either way the log is rescored, so a multiplier the removed contact held passes to the next contact that claims it. Both go out to the other stations in N1MM's own messages: contactreplace carries oldcall and oldtimestamp, contactdelete names the contact. An incoming edit or delete is matched by contact id first, falling back to call plus timestamp because N1MM does not know our ids. Country, continent and the two prefixes were filled in twice, once when logging and once when editing. They now come from CountryFields. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
4.3 KiB
C#
123 lines
4.3 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, edited or deleted. A logged or edited
|
|
/// contact arrives already scored; the log works its points and multipliers
|
|
/// out again from the rules.
|
|
public event EventHandler<ContactUpdate>? UpdateArrived;
|
|
|
|
public event EventHandler<string>? Failed;
|
|
|
|
public void Start() => loop ??= Task.Run(() => ListenAsync(stopping.Token));
|
|
|
|
public Task SendAsync(Qso qso, string myCallsign, CancellationToken cancellation = default) =>
|
|
SendTextAsync(ContactMessage.Write(qso, myCallsign, stationName), cancellation);
|
|
|
|
public Task SendEditAsync(
|
|
Qso qso,
|
|
string myCallsign,
|
|
string oldCall,
|
|
DateTime oldTimestampUtc,
|
|
CancellationToken cancellation = default) =>
|
|
SendTextAsync(
|
|
ContactMessage.WriteReplace(qso, myCallsign, stationName, oldCall, oldTimestampUtc),
|
|
cancellation);
|
|
|
|
public Task SendDeleteAsync(Qso qso, string myCallsign, CancellationToken cancellation = default) =>
|
|
SendTextAsync(ContactMessage.WriteDelete(qso, myCallsign, stationName), cancellation);
|
|
|
|
private async Task SendTextAsync(string xml, CancellationToken cancellation)
|
|
{
|
|
byte[] message = Encoding.UTF8.GetBytes(xml);
|
|
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);
|
|
ContactUpdate? update = ContactMessage.Read(Encoding.UTF8.GetString(received.Buffer));
|
|
if (update is not null && update.StationName != stationName)
|
|
{
|
|
UpdateArrived?.Invoke(this, update);
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
}
|