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 stations = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary 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 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 warnedAboutCountryFile = new(StringComparer.OrdinalIgnoreCase); /// The addresses already named as answering to this computer's name. private readonly HashSet 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 Stations => stations.Values.ToList(); /// A contact another station logged, edited or deleted. public event EventHandler? UpdateArrived; /// A line of chat from another operator. public event EventHandler? 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? 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? NumberReserved; /// The whole serial number pool from another station, one number per band /// in N1MM's band order. public event EventHandler>? SerialNumbersArrived; /// A callsign another station handed to this one's call stack, with the /// contact behind it when there was one. public event EventHandler? 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? 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 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 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 SendLoggedAsync(Qso qso, CancellationToken cancellation = default) => SendAsync(StationMessages.Logged(qso, StationNumber, computerName), cancellation); public Task SendEditedAsync( Qso qso, string oldCall, DateTime oldTimestampUtc, CancellationToken cancellation = default) => SendAsync( StationMessages.Edited(qso, oldCall, oldTimestampUtc, StationNumber, computerName), cancellation); public Task SendDeletedAsync(Qso qso, CancellationToken cancellation = default) => SendAsync(StationMessages.Deleted(qso, StationNumber, computerName), cancellation); public Task 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 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 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 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 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 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 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 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 SendSerialNumbersAsync( IReadOnlyList 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 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 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 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 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 Sent(Task 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 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 ""; } } }