Files
Nonemm/src/Nonemm.Network/StationLink.cs
T
erikbrockoandClaude Sonnet 5.5 085584a281 Fix the crash on changing network settings, and apply resynced contacts in batches
ApplyNetworkSettings disposed the station link without clearing it, so
ApplyStationLinkSettings disposed it a second time and threw. Only the
second function touches the link now, and StationLink.Dispose can be
called twice.

Contacts from the network are queued and applied together on the window
thread. Each one used to rescore the whole log and redraw the log window,
which kept the window thread busy for minutes during a resync.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:26:03 +00:00

1158 lines
46 KiB
C#

using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Text;
using Nonemm.Core;
namespace Nonemm.Network;
/// The link between the computers of one multi-operator entry, on N1MM's own
/// port 12070.
///
/// Two sockets, which is how N1MM does it:
///
/// | Socket | What goes over it |
/// |---|---|
/// | UDP, broadcast | the beacon that says a computer is here, every `BeaconInterval` |
/// | TCP, one per station | contacts, edits, deletes, chat, everything else |
///
/// A station is found rather than configured: the beacon carries the address
/// and the port to open a connection to, so an operator plugs a laptop in and
/// the others see it. A connection is opened to every station whose beacon
/// arrives, and both ends do it, so there are two connections per pair of
/// stations — one each way. That is N1MM's arrangement: a station writes to the
/// connection it opened and reads from the one that was opened to it.
///
/// **The version must match.** N1MM compares the version in the beacon with
/// its own and turns away anything else, so `version` is what this program
/// claims to be and has to be the version of the N1MM copies it is running
/// beside. A station that says something else is kept in the list with
/// `Refused` set, so the operator can see why it is not talking.
///
/// This does not score anything or touch the log. It hands what arrives to
/// whoever owns the log through `UpdateArrived`, the same as `StationNetwork`
/// does with the XML broadcasts on port 12060. The two run side by side: 12060
/// is for other programs, 12070 is for other logging computers.
public sealed class StationLink : IDisposable
{
/// How often the beacon goes out. N1MM broadcasts on startup, when the
/// network status window asks, and on a timer; ten seconds is short enough
/// that a station that joins is seen at once and long enough to be nothing
/// on a network carrying contest traffic.
public static readonly TimeSpan BeaconInterval = TimeSpan.FromSeconds(10);
/// The longest a computer name may be. It is a NetBIOS name, which is
/// fifteen characters, and N1MM asks for names inside that so every station
/// is known by the same name everywhere.
public const int LongestComputerName = 15;
/// How long a station may say nothing before the window shows it as gone.
/// Three beacons.
public static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);
private readonly int port;
private readonly string computerName;
private readonly string version;
private readonly CancellationTokenSource stopping = new();
private bool disposed;
private readonly ConcurrentDictionary<string, NetworkedStation> stations = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, TcpClient> writers = new(StringComparer.OrdinalIgnoreCase);
private readonly NetworkedStation mine;
/// When the last echo request went out, which is what the round trip is
/// measured against.
private DateTime echoSentAt;
private UdpClient? beacons;
private TcpListener? listener;
private readonly List<Task> loops = [];
/// The stations already told about a country file that is not this one's,
/// so the message is not repeated with every status that arrives.
private readonly HashSet<string> warnedAboutCountryFile = new(StringComparer.OrdinalIgnoreCase);
/// The addresses already named as answering to this computer's name.
private readonly HashSet<string> warnedAboutName = [];
/// The address this station's own beacon goes out with, so a beacon from
/// somewhere else with this station's name can be told apart from the echo
/// of our own.
private string myAddress = "";
public StationLink(string computerName, string version, int stationNumber = 1, int port = StationBeacon.DefaultPort)
{
this.computerName = computerName.ToUpperInvariant();
this.version = version;
this.port = port;
StationNumber = stationNumber;
mine = new NetworkedStation(this.computerName, "", port)
{
IsMine = true,
};
mine.StationNumber = stationNumber;
mine.Version = version;
stations[this.computerName] = mine;
}
/// Which station of the entry this computer is. It goes into every message
/// and into every contact this computer logs.
public int StationNumber { get; }
public string ComputerName => computerName;
/// Who is at this radio, sent in the beacon so the other stations can show
/// it.
public string Operator
{
get => mine.Operator;
set => mine.Operator = value;
}
/// The log a resync reads. Without one this station answers no checksum:
/// it has nothing to compare and nothing to send.
public ContactLog? Log { get; set; }
/// This computer's own row, which is in the station list as well. The
/// window reads its pass frequency and its counts off it.
public NetworkedStation Me => mine;
/// Every station, this computer included, in the order they were first
/// heard.
public IReadOnlyList<NetworkedStation> Stations => stations.Values.ToList();
/// A contact another station logged, edited or deleted.
public event EventHandler<ContactUpdate>? UpdateArrived;
/// A line of chat from another operator.
public event EventHandler<string>? TalkArrived;
/// Anything about the list of stations changed: one joined, one went quiet,
/// one moved band. The status window redraws on this.
public event EventHandler? StationsChanged;
public event EventHandler<string>? Failed;
/// Another station asked every station to start its serial numbers again
/// at one. The numbers come out of the log, which is not the link's, so the
/// owner is told and does it.
public event EventHandler? SerialNumbersReset;
/// A serial number another station has taken, with the frequency it took
/// it on. The pool is not the link's, so the owner is told and notes it.
public event EventHandler<ReservedNumberMessage>? NumberReserved;
/// The whole serial number pool from another station, one number per band
/// in N1MM's band order.
public event EventHandler<IReadOnlyList<int>>? SerialNumbersArrived;
/// A callsign another station handed to this one's call stack, with the
/// contact behind it when there was one.
public event EventHandler<StackedCall>? CallStacked;
/// Another station asked for a rescore when its resync is done. The log is
/// not this program's to score, so the owner is told and does it.
public event EventHandler? RescoreRequested;
/// Every message that arrives, whatever it says. Nothing needs this to run:
/// it is what the messages pane of the network status window prints when
/// the operator turns tracing on.
public event EventHandler<StationRecord>? MessageArrived;
public void Start()
{
if (loops.Count > 0)
{
return;
}
try
{
beacons = new UdpClient();
beacons.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
beacons.Client.Bind(new IPEndPoint(IPAddress.Any, port));
beacons.EnableBroadcast = true;
listener = new TcpListener(new IPEndPoint(IPAddress.Any, port));
listener.Start();
}
catch (SocketException e)
{
Failed?.Invoke(this, $"could not take port {port}: {e.Message}");
return;
}
if (computerName.Length > LongestComputerName)
{
Failed?.Invoke(
this,
$"this computer is called {computerName}, which is longer than the {LongestComputerName} characters N1MM allows — the other stations may know it by another name");
}
loops.Add(Task.Run(() => BeaconLoopAsync(stopping.Token)));
loops.Add(Task.Run(() => ListenForBeaconsAsync(stopping.Token)));
loops.Add(Task.Run(() => AcceptAsync(stopping.Token)));
}
/// A station named by hand rather than found by a beacon, and a connection
/// opened to it. N1MM offers the same thing for a network where a broadcast
/// does not reach every computer — its predefined stations — and it is also
/// what a station on the other side of a VPN needs.
public NetworkedStation AddStation(string name, string address, int stationPort)
{
NetworkedStation station = stations.GetOrAdd(
name.ToUpperInvariant(),
known => new NetworkedStation(known, address, stationPort));
station.Address = address;
station.Port = stationPort;
station.LastHeardUtc = DateTime.UtcNow;
StationsChanged?.Invoke(this, EventArgs.Empty);
if (!writers.ContainsKey(station.ComputerName))
{
_ = ConnectAsync(station, stopping.Token);
}
return station;
}
/// A message to every station that is connected. Returns how many it
/// reached, so a caller that has to know whether anybody heard can say so.
public async Task<int> SendAsync(StationRecord record, CancellationToken cancellation = default)
{
byte[] message = Encoding.UTF8.GetBytes(record.ToWire());
int reached = 0;
foreach ((string name, TcpClient writer) in writers)
{
try
{
await writer.GetStream().WriteAsync(message, cancellation).ConfigureAwait(false);
reached++;
if (stations.TryGetValue(name, out NetworkedStation? station))
{
station.Sent++;
}
}
catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
{
Drop(name, e.Message);
}
}
if (reached > 0)
{
mine.Sent += reached;
StationsChanged?.Invoke(this, EventArgs.Empty);
}
return reached;
}
/// A message to one station. Returns false when it could not be written,
/// which drops the connection: the next beacon opens a new one.
public async Task<bool> SendToAsync(
NetworkedStation station,
StationRecord record,
CancellationToken cancellation = default)
{
if (!writers.TryGetValue(station.ComputerName, out TcpClient? writer))
{
return false;
}
try
{
await writer
.GetStream()
.WriteAsync(Encoding.UTF8.GetBytes(record.ToWire()), cancellation)
.ConfigureAwait(false);
}
catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
{
Drop(station.ComputerName, e.Message);
return false;
}
station.Sent++;
mine.Sent++;
StationsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
public Task<int> SendLoggedAsync(Qso qso, CancellationToken cancellation = default) =>
SendAsync(StationMessages.Logged(qso, StationNumber, computerName), cancellation);
public Task<int> SendEditedAsync(
Qso qso,
string oldCall,
DateTime oldTimestampUtc,
CancellationToken cancellation = default) =>
SendAsync(
StationMessages.Edited(qso, oldCall, oldTimestampUtc, StationNumber, computerName),
cancellation);
public Task<int> SendDeletedAsync(Qso qso, CancellationToken cancellation = default) =>
SendAsync(StationMessages.Deleted(qso, StationNumber, computerName), cancellation);
public Task<int> SendTalkAsync(string text, CancellationToken cancellation = default) =>
SendAsync(StationMessages.Talk(text, StationNumber, computerName), cancellation);
/// Says where this station is tuned. It goes out whenever the radio moves;
/// the mode and whether the station is running go with the next `STATUS`.
public Task<int> SendFrequencyAsync(Frequency frequency, CancellationToken cancellation = default)
{
mine.Frequency = frequency;
StationsChanged?.Invoke(this, EventArgs.Empty);
return SendAsync(StationMessages.OnFrequency(frequency, StationNumber, computerName), cancellation);
}
/// This station started or stopped transmitting, and what it is sending.
/// `message` is the caption of the function key, which is what the other
/// operators read in the window.
public Task<int> SendTransmittingAsync(
bool transmitting,
string message = "",
bool isRun2 = false,
CancellationToken cancellation = default)
{
mine.IsTransmitting = transmitting;
mine.Message = transmitting ? message : "";
StationsChanged?.Invoke(this, EventArgs.Empty);
return SendAsync(
StationMessages.Transmitting(transmitting, message, isRun2, StationNumber, computerName),
cancellation);
}
/// Everything this station has to say about itself, which is what fills
/// the other operators' network status windows. It goes out every few
/// seconds whether anything has changed or not.
public Task<int> SendStatusAsync(StationStatus status, CancellationToken cancellation = default)
{
mine.PassFrequency = status.PassFrequency;
mine.Frequency = status.Frequency;
mine.IsRunning = status.IsRunning;
mine.Operator = status.Operator;
mine.Mode = status.Mode;
mine.Last10 = status.Last10;
mine.Last100 = status.Last100;
mine.IsRun2 = status.IsRun2;
StationsChanged?.Invoke(this, EventArgs.Empty);
return SendAsync(StationMessages.Status(status, version, StationNumber, computerName), cancellation);
}
/// A frequency this station is handing to the others. With `to` set it goes
/// to that station alone, which is how an operator passes one contact to one
/// position; without it every station is told, which is how the pass
/// frequency is announced.
public Task<int> SendPassAsync(
Frequency frequency,
NetworkedStation? to = null,
CancellationToken cancellation = default)
{
mine.PassFrequency = frequency;
StationsChanged?.Invoke(this, EventArgs.Empty);
StationRecord record = StationMessages.PassFrequency(frequency, StationNumber, computerName);
return to is null
? SendAsync(record, cancellation)
: Sent(SendToAsync(to, record, cancellation));
}
/// Closes the connection to one station because the operator asked. It
/// stays closed: a beacon from that station opens a new one, so without
/// this flag the connection would be back within ten seconds.
public void Close(NetworkedStation station)
{
station.IsReconnectPrevented = true;
Drop(station.ComputerName, "closed by the operator");
}
/// Opens a connection to a station the operator closed, or to one whose
/// connection has not come back on its own.
public void Open(NetworkedStation station)
{
station.IsReconnectPrevented = false;
if (!station.IsMine && !writers.ContainsKey(station.ComputerName))
{
_ = ConnectAsync(station, stopping.Token);
}
}
/// How long a block of the log a resync compares at a time. N1MM uses an
/// hour, which is why its counts are of hours.
public static readonly TimeSpan ResyncBlock = TimeSpan.FromHours(1);
/// Starts a resync with one station: are your contacts and my copies of
/// them the same, hour by hour, between these two times?
///
/// It is a walk rather than one message. This station sends the checksum of
/// the first block, the other end answers with its own and sends the block
/// again if they differ, and its answer is what asks for the next block. The
/// walk stops when the block being asked about starts after `toUtc`.
public void StartResync(
NetworkedStation station,
DateTime fromUtc,
DateTime toUtc,
bool everyStation = false,
bool rescoreWhenDone = false)
{
station.IsResyncing = true;
station.ResyncStatus = "Starting";
station.HoursOk = 0;
station.HoursFixed = 0;
station.HoursFailed = 0;
StationsChanged?.Invoke(this, EventArgs.Empty);
_ = AskAboutBlockAsync(station, fromUtc, toUtc, everyStation, rescoreWhenDone);
}
/// The same walk with every station that is connected, which is N1MM's
/// Resync All.
public void StartResyncAll(
DateTime fromUtc,
DateTime toUtc,
bool everyStation = false,
bool rescoreWhenDone = false)
{
foreach (NetworkedStation station in stations.Values)
{
if (!station.IsMine && station.IsConnected)
{
StartResync(station, fromUtc, toUtc, everyStation, rescoreWhenDone);
}
}
}
/// This station's clock, so the others can see whether theirs agree.
/// N1MM sets the clock from it when it is running as an administrator; this
/// program says how far off it is and leaves the clock alone, which is what
/// N1MM does the rest of the time.
public Task<int> SendTimeAsync(CancellationToken cancellation = default) =>
SendAsync(StationMessages.Time(DateTime.UtcNow, StationNumber, computerName), cancellation);
/// Names the station the whole entry follows for cluster spots and the
/// time. Every station is told, this one included, so the window agrees
/// everywhere.
public Task<int> SendMasterAsync(NetworkedStation master, CancellationToken cancellation = default)
{
MarkMaster(master.ComputerName);
return SendAsync(StationMessages.Master(master.ComputerName, StationNumber, computerName), cancellation);
}
/// Marks the station stacked callsigns go to, or none with null. It is
/// only this station's choice: N1MM keeps the target at the station doing
/// the sending and tells nobody.
public void SetCallStackTarget(NetworkedStation? target)
{
foreach (NetworkedStation station in stations.Values)
{
station.IsCallStackTarget = ReferenceEquals(station, target);
}
StationsChanged?.Invoke(this, EventArgs.Empty);
}
/// Says which serial number this station has taken for the contact it is
/// about to work. It goes out at the first character of the callsign, so
/// the other operators' next number is above this one.
public Task<int> SendReservedNumberAsync(
int number,
Frequency frequency,
CancellationToken cancellation = default) =>
SendAsync(
StationMessages.ReserveNumber(number, frequency, StationNumber, computerName),
cancellation);
/// Sends the highest serial number taken on each band, which is the answer
/// to a station that has claimed a number this one has already used.
public Task<int> SendSerialNumbersAsync(
IReadOnlyList<int> numbers,
CancellationToken cancellation = default) =>
SendAsync(
StationMessages.SerialNumbers(
Log?.ContestName ?? "",
numbers,
StationNumber,
computerName),
cancellation);
/// Tells every station to count its serial numbers from the log again.
public Task<int> SendResetSerialNumbersAsync(CancellationToken cancellation = default) =>
SendAsync(StationMessages.ResetSerialNumbers(StationNumber, computerName), cancellation);
/// Which contest this station is logging. It goes out when a connection
/// opens and whenever the operator opens another contest.
public Task<int> SendContestNameAsync(CancellationToken cancellation = default) =>
Log is null
? Task.FromResult(0)
: SendAsync(
StationMessages.ContestName(Log.ContestName, Log.ContestSubType, StationNumber, computerName),
cancellation);
/// Hands a callsign to another station's call stack. With a contact behind
/// it the other end gets the frequency and the exchange as well.
public Task<bool> SendStackedAsync(
NetworkedStation to,
string call,
Qso? contact = null,
CancellationToken cancellation = default) =>
SendToAsync(
to,
contact is null
? StationMessages.StackCall(call, StationNumber, computerName)
: StationMessages.StackContact(contact, StationNumber, computerName),
cancellation);
/// Asks every station whether it is there. The answer sets `EchoTime`.
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
{
echoSentAt = DateTime.UtcNow;
return SendAsync(StationMessages.EchoRequest(StationNumber, computerName, DateTime.UtcNow), cancellation);
}
/// One send read as the count every other send returns.
private static async Task<int> Sent(Task<bool> one) =>
await one.ConfigureAwait(false) ? 1 : 0;
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
stopping.Cancel();
foreach (TcpClient writer in writers.Values)
{
writer.Dispose();
}
writers.Clear();
listener?.Stop();
beacons?.Dispose();
stopping.Dispose();
}
/// The beacon, on every interface that can broadcast. It carries this
/// computer's name, address and port, so a station that hears it knows
/// where to open a connection.
private async Task BeaconLoopAsync(CancellationToken cancellation)
{
while (!cancellation.IsCancellationRequested)
{
try
{
StationBeacon beacon = new(computerName, Address(), port, version, mine.Operator);
myAddress = beacon.Address;
byte[] message = Encoding.UTF8.GetBytes(beacon.ToWire());
await beacons!
.SendAsync(message, new IPEndPoint(IPAddress.Broadcast, port), cancellation)
.ConfigureAwait(false);
mine.Address = beacon.Address;
mine.LastHeardUtc = DateTime.UtcNow;
Forget();
}
catch (OperationCanceledException)
{
return;
}
catch (SocketException e)
{
Failed?.Invoke(this, $"could not broadcast: {e.Message}");
}
try
{
await Task.Delay(BeaconInterval, cancellation).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
}
}
private async Task ListenForBeaconsAsync(CancellationToken cancellation)
{
while (!cancellation.IsCancellationRequested)
{
try
{
UdpReceiveResult received = await beacons!.ReceiveAsync(cancellation).ConfigureAwait(false);
if (StationBeacon.Read(Encoding.UTF8.GetString(received.Buffer)) is not { } beacon)
{
continue;
}
if (beacon.ComputerName.Equals(computerName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
Heard(beacon, received.RemoteEndPoint.Address.ToString());
}
catch (OperationCanceledException)
{
return;
}
catch (SocketException e)
{
Failed?.Invoke(this, e.Message);
}
}
}
/// A station said it is here. Its own address is believed only as far as
/// the packet: a station behind a router announces an address nothing can
/// reach, so the address the packet came from is what a connection is
/// opened to.
private void Heard(StationBeacon beacon, string from)
{
if (string.Equals(beacon.ComputerName, computerName, StringComparison.OrdinalIgnoreCase))
{
// our own beacon comes back to us: every other computer answering
// to this name is a second station calling itself the same thing,
// and their contacts would be stamped with the same name
if (myAddress.Length > 0 && from != myAddress && warnedAboutName.Add(from))
{
Failed?.Invoke(
this,
$"another computer at {from} is also calling itself {computerName} — give one of them another name");
}
return;
}
NetworkedStation station = stations.GetOrAdd(
beacon.ComputerName,
name => new NetworkedStation(name, from, beacon.Port));
station.Address = from;
station.Port = beacon.Port;
station.Version = beacon.Version;
station.Operator = beacon.Operator;
station.LastHeardUtc = DateTime.UtcNow;
if (beacon.Version != version)
{
station.Refused = $"version {beacon.Version}, this station is {version}";
station.IsConnected = false;
StationsChanged?.Invoke(this, EventArgs.Empty);
return;
}
station.Refused = "";
StationsChanged?.Invoke(this, EventArgs.Empty);
if (!station.IsReconnectPrevented && !writers.ContainsKey(station.ComputerName))
{
_ = ConnectAsync(station, stopping.Token);
}
}
private async Task ConnectAsync(NetworkedStation station, CancellationToken cancellation)
{
TcpClient writer = new();
try
{
await writer.ConnectAsync(station.Address, station.Port, cancellation).ConfigureAwait(false);
}
catch (Exception e) when (e is SocketException or OperationCanceledException)
{
writer.Dispose();
station.Refused = e.Message;
Failed?.Invoke(this, $"could not open a connection to {station.ComputerName} at {station.Address}:{station.Port}: {e.Message}");
StationsChanged?.Invoke(this, EventArgs.Empty);
return;
}
if (!writers.TryAdd(station.ComputerName, writer))
{
writer.Dispose();
return;
}
station.IsConnected = true;
station.LastHeardUtc = DateTime.UtcNow;
StationsChanged?.Invoke(this, EventArgs.Empty);
// N1MM's first message on a new connection, which tells the other end
// which station of the entry this is. It goes to that station alone:
// the others were told when their own connection opened
await SendToAsync(station, StationMessages.IAm(StationNumber, computerName), cancellation)
.ConfigureAwait(false);
// which contest this station is logging, so the other end can say so
// before the two of them resynchronise into the wrong log
if (Log is not null)
{
await SendToAsync(
station,
StationMessages.ContestName(Log.ContestName, Log.ContestSubType, StationNumber, computerName),
cancellation).ConfigureAwait(false);
}
// when this station last worked anybody. The other end holds contacts
// of ours only up to some time of its own, and starts a resync when
// this one is later
if (Log is not null)
{
await SendToAsync(
station,
StationMessages.LastContactAt(Log.LastContactUtc(computerName), StationNumber, computerName),
cancellation).ConfigureAwait(false);
}
// that station told us it was ahead of us before there was anything to
// answer over
if (station.ResyncWanted is { } behindSince)
{
station.ResyncWanted = null;
StartResync(station, behindSince, DateTime.UtcNow);
}
}
/// How far apart two clocks may be before it is worth saying so. N1MM
/// ignores a second either way.
private static readonly TimeSpan ClockPatience = TimeSpan.FromSeconds(2);
private void MarkMaster(string name)
{
foreach (NetworkedStation station in stations.Values)
{
station.IsMaster = string.Equals(station.ComputerName, name, StringComparison.OrdinalIgnoreCase);
}
StationsChanged?.Invoke(this, EventArgs.Empty);
}
/// Says so when another station is logging a different contest. It is
/// worth saying twice: the contacts arriving from it are being scored into
/// this log against rules that are not theirs.
private void WarnAboutContest(NetworkedStation station, int fieldCount)
{
if (Log is null)
{
return;
}
// N1MM counts the fields of this message and says a station with fewer
// of them is on another version. The trailing separator leaves an empty
// one on the end, which is the fourth
if (fieldCount < 4)
{
Failed?.Invoke(this, $"{station.ComputerName} is running another version of the program");
}
if (!string.Equals(station.ContestName, Log.ContestName, StringComparison.OrdinalIgnoreCase))
{
Failed?.Invoke(
this,
$"{station.ComputerName} is logging {station.ContestName}, this station is logging {Log.ContestName}");
}
else if (station.ContestSubType.Length > 0
&& Log.ContestSubType.Length > 0
&& !string.Equals(station.ContestSubType, Log.ContestSubType, StringComparison.OrdinalIgnoreCase))
{
Failed?.Invoke(
this,
$"{station.ComputerName} is logging {station.ContestName} for {station.ContestSubType}, this station for {Log.ContestSubType}");
}
}
private void WarnAboutClock(NetworkedStation station)
{
if (station.ClockOffset is not { } offset || offset.Duration() <= ClockPatience)
{
return;
}
string which = offset > TimeSpan.Zero ? "ahead of" : "behind";
Failed?.Invoke(
this,
$"{station.ComputerName}'s clock is {offset.Duration().TotalSeconds:0} s {which} this one — set one of them");
}
/// Says so once when another station is scoring with a different issue of
/// the country file. Two issues score the same contact differently, and the
/// two stations then disagree about the score all contest.
private void WarnAboutCountryFile(NetworkedStation station)
{
if (Log is null
|| station.CountryFileVersion.Length == 0
|| Log.CountryFileVersion.Length == 0
|| station.CountryFileVersion == Log.CountryFileVersion
|| !warnedAboutCountryFile.Add(station.ComputerName))
{
return;
}
Failed?.Invoke(
this,
$"{station.ComputerName} has country file {station.CountryFileVersion}, this station has {Log.CountryFileVersion}");
}
/// A station said when it last worked anybody. When this station holds
/// nothing of its that late, the hours in between are missing and a resync
/// asks for them.
///
/// An empty log asks for nothing: a station that has just been started has
/// no business pulling the whole contest across, and N1MM suppresses it the
/// same way.
private void ResyncIfBehind(NetworkedStation station, DateTime theirLast)
{
if (Log is null || station.IsResyncing || theirLast == default)
{
return;
}
DateTime ourLast = Log.LastContactUtc(station.ComputerName);
if (theirLast <= ourLast)
{
return;
}
// nothing at all from that station: everything it worked since this log
// started is missing
DateTime from = ourLast == default ? Log.FirstContactUtc : ourLast;
if (from == default)
{
Failed?.Invoke(
this,
$"{station.ComputerName} has worked somebody since this log was started, and this log is empty — resync by hand");
return;
}
Failed?.Invoke(this, $"resynchronising with {station.ComputerName}, which has contacts this log has not");
// the message arrived over the connection that station opened, and the
// answer goes over the one this station opens, which may not be up yet
if (writers.ContainsKey(station.ComputerName))
{
StartResync(station, from, DateTime.UtcNow);
}
else
{
station.ResyncWanted = from;
}
}
private async Task AcceptAsync(CancellationToken cancellation)
{
while (!cancellation.IsCancellationRequested)
{
try
{
TcpClient reader = await listener!.AcceptTcpClientAsync(cancellation).ConfigureAwait(false);
_ = Task.Run(() => ReadAsync(reader, cancellation), cancellation);
}
catch (OperationCanceledException)
{
return;
}
catch (SocketException e)
{
Failed?.Invoke(this, e.Message);
}
}
}
/// One connection that was opened to this station. Whatever arrives is
/// added to what has not been read yet, and every whole message in it is
/// handed on: TCP gives no promise about where a read ends.
private async Task ReadAsync(TcpClient reader, CancellationToken cancellation)
{
byte[] buffer = new byte[8192];
string held = "";
try
{
using (reader)
{
NetworkStream stream = reader.GetStream();
while (!cancellation.IsCancellationRequested)
{
int count = await stream.ReadAsync(buffer, cancellation).ConfigureAwait(false);
if (count == 0)
{
return;
}
held += Encoding.UTF8.GetString(buffer, 0, count);
while (StationRecord.Read(ref held) is { } record)
{
Arrived(record);
}
}
}
}
catch (Exception e) when (e is IOException or SocketException or OperationCanceledException)
{
}
}
/// What one message means. The station it came from is credited with it
/// whatever the type is, so the window's Read count and Last heard are
/// right even for the messages this program does nothing with.
private void Arrived(StationRecord record)
{
NetworkedStation station = stations.GetOrAdd(
record.ComputerName,
name => new NetworkedStation(name, "", port));
station.LastHeardUtc = DateTime.UtcNow;
station.LastMessage = record.Type;
station.Read++;
mine.Read++;
MessageArrived?.Invoke(this, record);
switch (record.Type)
{
case "IAM":
station.StationNumber = record.Number(0);
if (station.StationNumber == StationNumber && !station.IsMine)
{
Failed?.Invoke(
this,
$"{station.ComputerName} is station {station.StationNumber} as well — every station of an entry needs its own number");
}
break;
case "FREQ":
station.Frequency = Frequency.FromKilohertz(record.Decimal(0) / 100);
break;
case "LASTQAT":
ResyncIfBehind(station, record.Time(0));
break;
case "STATUS":
StationStatus status = StationMessages.ReadStatus(record);
station.PassFrequency = status.PassFrequency;
station.Frequency = status.Frequency;
station.IsRunning = status.IsRunning;
station.Operator = status.Operator;
station.Mode = status.Mode;
station.Last10 = status.Last10;
station.Last100 = status.Last100;
station.CountryFileVersion = status.CountryFileVersion;
station.IsRun2 = status.IsRun2;
WarnAboutCountryFile(station);
break;
case "XMIT":
station.IsTransmitting = record.Flag(0);
station.Message = station.IsTransmitting ? record.Field(1) : "";
station.IsRun2 = record.Flag(2);
break;
case "PASSFREQ":
station.PassFrequency = Frequency.FromKilohertz(record.Decimal(0) / 100);
break;
case "ECHOREQ":
_ = SendAsync(StationMessages.Echo(record, StationNumber, computerName));
break;
case "ECHO":
station.EchoTime = DateTime.UtcNow - echoSentAt;
break;
case "CHECKSUM":
_ = AnswerChecksumAsync(station, record);
break;
case "CONFIRMED":
_ = TakeConfirmationAsync(station, record);
break;
case "DELETEQS":
station.HoursFixed++;
station.ResyncStatus = $"Replacing {record.Time(0):yyyy-MM-dd HH:mm}Z";
if (StationMessages.Read(record) is { } cleared)
{
UpdateArrived?.Invoke(this, cleared);
}
break;
case "CONTESTNAME":
station.ContestName = record.Field(1);
station.ContestSubType = record.Field(2);
WarnAboutContest(station, record.Fields.Count);
break;
case "TIME":
station.ClockOffset = record.Time(0) - DateTime.UtcNow;
WarnAboutClock(station);
break;
case "MASTER":
MarkMaster(record.Field(0));
break;
case "RESETQSONRS":
SerialNumbersReset?.Invoke(this, EventArgs.Empty);
break;
case "RESERVENR":
NumberReserved?.Invoke(
this,
new ReservedNumberMessage(
record.Number(0),
Frequency.FromKilohertz(record.Decimal(1) / 100)));
break;
case "QSONRS":
// the pool is per contest, so a station logging another one is
// counting something else
if (Log is null || record.Field(0).Equals(Log.ContestName, StringComparison.OrdinalIgnoreCase))
{
SerialNumbersArrived?.Invoke(this, StationMessages.ReadSerialNumbers(record));
}
break;
case "STACKANOTHERCALL":
CallStacked?.Invoke(this, new StackedCall(record.Field(0), null, station.ComputerName));
break;
case "STACKCALL":
Qso stacked = QsoRecord.Read(record, 0, station.ComputerName);
CallStacked?.Invoke(this, new StackedCall(stacked.Call.Text, stacked, station.ComputerName));
break;
case "TALK":
TalkArrived?.Invoke(this, record.Field(0));
break;
default:
if (StationMessages.Read(record) is { } update)
{
UpdateArrived?.Invoke(this, update);
}
break;
}
StationsChanged?.Invoke(this, EventArgs.Empty);
}
/// One block of the walk: what this station holds for the other one, sent
/// as a checksum for it to compare with its own.
private async Task AskAboutBlockAsync(
NetworkedStation station,
DateTime fromUtc,
DateTime toUtc,
bool everyStation,
bool rescoreWhenDone)
{
if (Log is null)
{
return;
}
int checksum = LogChecksum.Of(Log.Contacts(station.ComputerName, fromUtc, ResyncBlock));
bool sent = await SendToAsync(
station,
StationMessages.CheckSum(
fromUtc,
toUtc,
checksum,
Log.ContestName,
Log.MyCall,
(int)ResyncBlock.TotalMinutes,
everyStation,
rescoreWhenDone,
StationNumber,
computerName)).ConfigureAwait(false);
if (!sent)
{
// nothing to ask over. Left running, the row would say it is
// resynchronising for the rest of the contest
station.IsResyncing = false;
station.ResyncStatus = "Stopped: no connection";
StationsChanged?.Invoke(this, EventArgs.Empty);
}
}
/// A station asked whether we hold the same block. This end is the
/// authority on the contacts it made: when the two checksums differ it
/// tells the asker to drop its copies and sends the block again, and it
/// answers with its own checksum either way.
private async Task AnswerChecksumAsync(NetworkedStation station, StationRecord record)
{
if (Log is null)
{
return;
}
DateTime fromUtc = record.Time(0);
DateTime toUtc = record.Time(1);
int theirs = record.Number(2);
string contest = record.Field(3);
string call = record.Field(4);
TimeSpan block = TimeSpan.FromMinutes(Math.Max(1, record.Number(5)));
bool everyStation = record.Flag(6);
if (record.Flag(7))
{
RescoreRequested?.Invoke(this, EventArgs.Empty);
}
if (!string.Equals(contest, Log.ContestName, StringComparison.OrdinalIgnoreCase))
{
Failed?.Invoke(
this,
$"{station.ComputerName} is logging {contest}, this station is logging {Log.ContestName} — not resynchronising");
return;
}
if (call.Length > 0 && !string.Equals(call, Log.MyCall, StringComparison.OrdinalIgnoreCase))
{
Failed?.Invoke(this, $"{station.ComputerName} is logging as {call}, this station as {Log.MyCall}");
}
IReadOnlyList<Qso> mine = Log.Contacts(computerName, fromUtc, block);
int ours = LogChecksum.Of(mine);
if (everyStation)
{
StartResyncAll(fromUtc, toUtc);
}
if (theirs != ours)
{
await SendToAsync(
station,
StationMessages.DeleteContacts(
fromUtc,
computerName,
(int)block.TotalMinutes,
StationNumber,
computerName)).ConfigureAwait(false);
foreach (Qso qso in mine)
{
await SendToAsync(station, StationMessages.Resynced(qso, StationNumber, computerName))
.ConfigureAwait(false);
}
}
await SendToAsync(
station,
StationMessages.Confirmed(
fromUtc,
toUtc,
ours,
(int)block.TotalMinutes,
StationNumber,
computerName)).ConfigureAwait(false);
}
/// The answer to one block. It says how that block went and asks about the
/// next one, which is what walks the resync along; a block that starts
/// after the end of the period is the end of the walk.
private async Task TakeConfirmationAsync(NetworkedStation station, StationRecord record)
{
if (Log is null)
{
return;
}
DateTime fromUtc = record.Time(0);
DateTime toUtc = record.Time(1);
int theirs = record.Number(2);
TimeSpan block = TimeSpan.FromMinutes(Math.Max(1, record.Number(3)));
if (fromUtc > toUtc)
{
station.IsResyncing = false;
station.ResyncStatus = "Resync complete";
StationsChanged?.Invoke(this, EventArgs.Empty);
return;
}
// the contacts arrived before this answer did, so what is counted here
// is the block as it now stands
if (LogChecksum.Of(Log.Contacts(station.ComputerName, fromUtc, block)) == theirs)
{
station.HoursOk++;
}
else
{
station.HoursFailed++;
}
DateTime next = fromUtc + block;
station.ResyncStatus = $"Syncing {next:yyyy-MM-dd HH:mm}Z";
StationsChanged?.Invoke(this, EventArgs.Empty);
await AskAboutBlockAsync(station, next, toUtc, everyStation: false, rescoreWhenDone: false)
.ConfigureAwait(false);
}
/// Closes a connection that has failed or gone quiet. The station stays in
/// the list: it
/// is still part of the entry, and the window showing it as not connected
/// is the point.
private void Drop(string name, string why)
{
if (writers.TryRemove(name, out TcpClient? writer))
{
writer.Dispose();
}
if (stations.TryGetValue(name, out NetworkedStation? station))
{
station.IsConnected = false;
station.Refused = why;
}
StationsChanged?.Invoke(this, EventArgs.Empty);
}
/// Marks as not connected any station that has said nothing for
/// `Patience`, and lets go of its connection so the next beacon opens a
/// new one.
private void Forget()
{
DateTime cutoff = DateTime.UtcNow - Patience;
foreach (NetworkedStation station in stations.Values)
{
if (!station.IsMine && station.IsConnected && station.LastHeardUtc < cutoff)
{
Drop(station.ComputerName, "said nothing for half a minute");
}
}
}
/// This computer's address on the network it can reach the others on. It is
/// only for the beacon: the other end uses the address the packet came
/// from, so a wrong answer here costs nothing.
private static string Address()
{
try
{
using Socket probe = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
probe.Connect("8.8.8.8", 65530);
return probe.LocalEndPoint is IPEndPoint local ? local.Address.ToString() : "";
}
catch (SocketException)
{
return "";
}
}
}