diff --git a/src/Nonemm.Network/NetworkedStation.cs b/src/Nonemm.Network/NetworkedStation.cs new file mode 100644 index 0000000..c5176d0 --- /dev/null +++ b/src/Nonemm.Network/NetworkedStation.cs @@ -0,0 +1,98 @@ +using Nonemm.Core; + +namespace Nonemm.Network; + +/// One computer on the network, as far as this one can tell. The network status +/// window shows a row per station and these are its columns. +/// +/// Everything here is what the station last said, with the time it said it. +/// Nothing is asked for on demand: the beacons and the messages carry it, and a +/// station that has gone quiet keeps the last thing it said with an older +/// `LastHeardUtc`, which is how the window shows it as missing rather than +/// blank. +public sealed class NetworkedStation +{ + public NetworkedStation(string computerName, string address, int port) + { + ComputerName = computerName; + Address = address; + Port = port; + // now is when it was heard of, whether that was a beacon or the + // operator naming it. Left at nothing, `StationLink.Forget` reads a + // station as having been quiet since the beginning of time and drops + // the connection to it the moment it is opened + LastHeardUtc = DateTime.UtcNow; + } + + /// The name in every message the station sends, and what a contact from it + /// is stamped with. N1MM uses the computer's own network name. + public string ComputerName { get; } + + public string Address { get; internal set; } + + public int Port { get; internal set; } + + /// Which station of the entry this is. N1MM numbers them so a contact can + /// say which position made it; the number arrives in `IAM`. + public int StationNumber { get; internal set; } + + /// The version the station broadcast. N1MM refuses to talk to a station + /// whose version is not its own, so a mismatch here is why nothing works. + public string Version { get; internal set; } = ""; + + public string Operator { get; internal set; } = ""; + + public string ContestName { get; internal set; } = ""; + + /// Where the station is, from the last `BANDMAP` it sent. + public Frequency Frequency { get; internal set; } + + public Mode? Mode { get; internal set; } + + public Band? Band => Bands.ForFrequency(Frequency); + + /// True while the station is running rather than searching. In a + /// multi-single entry this is the run station. + public bool IsRunning { get; internal set; } + + public bool IsTransmitting { get; internal set; } + + public int RadioNumber { get; internal set; } = 1; + + /// A frequency this station has passed, from `PASSFREQ`, and who is on it. + public Frequency PassFrequency { get; internal set; } + + public string PassCall { get; internal set; } = ""; + + /// When anything last arrived from the station, and what it was. The window + /// shows both: a station whose last message is minutes old is not there any + /// more, whatever its connection says. + public DateTime LastHeardUtc { get; internal set; } + + public string LastMessage { get; internal set; } = ""; + + /// How many messages have gone each way since the program started. N1MM + /// shows the same two numbers, and a Send that climbs while Read stands + /// still is a station that is not listening. + public int Sent { get; internal set; } + + public int Read { get; internal set; } + + /// True while a connection to the station is open. It is not the same as + /// the station being there: the connection can stand for a while after the + /// other program has stopped. + public bool IsConnected { get; internal set; } + + /// How long the last echo took to come back, or null when none has. + public TimeSpan? EchoTime { get; internal set; } + + /// This computer, which is in the list as well. N1MM shows it too, so an + /// operator can read its own station number and version off the same + /// window. + public bool IsMine { get; internal init; } + + /// Set when the station broadcast a version that is not ours. N1MM turns + /// such a station away, so this program says why rather than failing to + /// connect for no visible reason. + public string Refused { get; internal set; } = ""; +} diff --git a/src/Nonemm.Network/QsoRecord.cs b/src/Nonemm.Network/QsoRecord.cs new file mode 100644 index 0000000..a654caf --- /dev/null +++ b/src/Nonemm.Network/QsoRecord.cs @@ -0,0 +1,142 @@ +using System.Globalization; +using Nonemm.Core; + +namespace Nonemm.Network; + +/// A contact as it travels between two logging computers: the thirty-five +/// fields of N1MM's `QSOString`, in N1MM's order. +/// +/// The order is what matters and it cannot be changed, because the other end +/// reads the fields by position. It is taken from `MultiOpManager.cs:1007`. +/// +/// | At | Field | At | Field | +/// |---|---|---|---| +/// | 0 | timestamp | 18 | sent serial | +/// | 1 | callsign | 19 | points | +/// | 2 | frequency, kHz | 20 | multiplier 1 | +/// | 3 | transmit frequency | 21 | multiplier 2 | +/// | 4 | mode | 22 | power | +/// | 5 | contest name | 23 | band, MHz | +/// | 6 | sent report | 24 | WPX prefix | +/// | 7 | received report | 25 | exchange 1 | +/// | 8 | country prefix | 26 | radio number | +/// | 9 | station prefix | 27 | operator | +/// | 10 | QTH | 28 | grid square | +/// | 11 | name | 29 | contest number | +/// | 12 | comment | 30 | multiplier 3 | +/// | 13 | received serial | 31 | misc text | +/// | 14 | section | 32 | contact type | +/// | 15 | precedence | 33 | run 1 or run 2 | +/// | 16 | check | 34 | continent | +/// | 17 | zone | | | +/// +/// The contact carries its points and its multiplier flags, and they are read +/// rather than trusted: the log works them out again from the contest rules, so +/// two stations cannot disagree about a score because one of them was running +/// an older set of rules. +public static class QsoRecord +{ + /// How many fields there are. A message with fewer is read as far as it + /// goes, because a station on another version sends a shorter one. + public const int FieldCount = 35; + + public static List Write(Qso qso) => + [ + StationRecord.Written(qso.TimestampUtc), + qso.Call.Text, + StationRecord.Written(qso.Frequency.Kilohertz), + StationRecord.Written( + (qso.QsxFrequency.Hertz == 0 ? qso.Frequency : qso.QsxFrequency).Kilohertz), + qso.Mode.Name, + qso.ContestName, + qso.SentReport, + qso.ReceivedReport, + qso.CountryPrefix, + qso.StationPrefix, + qso.Qth, + qso.Name, + qso.Comment, + Text(qso.ReceivedNumber), + qso.Section, + qso.Precedence, + Text(qso.Check), + Text(qso.Zone), + Text(qso.SentNumber), + Text(qso.Points), + StationRecord.Written(qso.IsMultiplier1), + StationRecord.Written(qso.IsMultiplier2), + qso.Power, + StationRecord.Written(qso.Band?.MegahertzLabel ?? 0), + qso.WpxPrefix, + qso.Exchange1, + Text(qso.RadioNumber), + qso.Operator, + qso.GridSquare, + Text(qso.ContestNumber), + StationRecord.Written(qso.IsMultiplier3), + qso.MiscText, + qso.ContactType, + Text(qso.RunPosition), + qso.Continent, + ]; + + /// The contact the fields describe. `at` is where the fields start, which + /// is not always 0: an edit puts the old timestamp in front of them and a + /// replace the old callsign as well. + /// + /// `stationName` is the computer that sent it, kept on the contact so the + /// log window can say where each row came from. + /// + /// The band, field 23, is not read back: this program works the band out + /// from the frequency, and reading it would believe a station that + /// disagreed with the band plan about where 14.2 MHz is. + public static Qso Read(StationRecord record, int at, string stationName) + { + Frequency frequency = Frequency.FromKilohertz(record.Decimal(at + 2)); + Frequency transmit = Frequency.FromKilohertz(record.Decimal(at + 3)); + return new Qso + { + Id = Qso.NewId(), + TimestampUtc = record.Time(at), + Call = Callsign.Parse(record.Field(at + 1)), + Frequency = frequency, + QsxFrequency = transmit == frequency ? Frequency.Zero : transmit, + Mode = Modes.Parse(record.Field(at + 4)) ?? Modes.Cw, + ContestName = record.Field(at + 5), + SentReport = record.Field(at + 6), + ReceivedReport = record.Field(at + 7), + CountryPrefix = record.Field(at + 8), + StationPrefix = record.Field(at + 9), + Qth = record.Field(at + 10), + Name = record.Field(at + 11), + Comment = record.Field(at + 12), + ReceivedNumber = record.Number(at + 13), + Section = record.Field(at + 14), + Precedence = record.Field(at + 15), + Check = record.Number(at + 16), + Zone = record.Number(at + 17), + SentNumber = record.Number(at + 18), + Points = record.Number(at + 19), + IsMultiplier1 = record.Flag(at + 20), + IsMultiplier2 = record.Flag(at + 21), + Power = record.Field(at + 22), + WpxPrefix = record.Field(at + 24), + Exchange1 = record.Field(at + 25), + RadioNumber = Math.Max(1, record.Number(at + 26)), + Operator = record.Field(at + 27), + GridSquare = record.Field(at + 28), + ContestNumber = record.Number(at + 29), + IsMultiplier3 = record.Flag(at + 30), + MiscText = record.Field(at + 31), + ContactType = record.Field(at + 32), + RunPosition = record.Number(at + 33), + Continent = record.Field(at + 34), + StationName = stationName, + NetworkedComputerNumber = record.StationNumber, + // it was made at another radio, on another computer + IsOriginal = false, + }; + } + + private static string Text(int number) => number.ToString(CultureInfo.InvariantCulture); +} diff --git a/src/Nonemm.Network/StationBeacon.cs b/src/Nonemm.Network/StationBeacon.cs new file mode 100644 index 0000000..666ce46 --- /dev/null +++ b/src/Nonemm.Network/StationBeacon.cs @@ -0,0 +1,71 @@ +using System.Globalization; + +namespace Nonemm.Network; + +/// The message every computer broadcasts to say it is on the network, which is +/// how the others find it without anybody typing in an address. +/// +/// It goes out as a UDP broadcast to port 12070 and carries six fields: +/// +/// | Field | What it is | +/// |---|---| +/// | `ComputerName` | the name the station is known by, and the name in every message it sends | +/// | `Address` | the address the others open a connection to | +/// | `Port` | the port they open it on, 12070 unless the operator moved it | +/// | `Version` | the program version, which has to match — see below | +/// | `Operator` | who is at that radio | +/// | `VpnAdapter` | the adapter the beacon went out of, or empty on an ordinary network | +/// +/// **N1MM will not talk to a station whose version is not its own.** It +/// compares the fourth field with its own version and, when they differ, puts +/// up "Software versions must match. Update N1MM+." and drops the station. So +/// the version this program broadcasts is a setting rather than its own +/// version: to work alongside N1MM it has to say what that copy of N1MM says. +/// A beacon with the wrong number of fields is refused the same way. +public sealed record StationBeacon( + string ComputerName, + string Address, + int Port, + string Version, + string Operator, + string VpnAdapter = "") +{ + /// N1MM's port for talking to another copy of itself. It listens on both + /// UDP and TCP here: the beacons arrive on the first and the contacts on + /// the second. + public const int DefaultPort = 12070; + + /// How many fields N1MM requires. It splits on `%` and counts, and the + /// trailing separator leaves an empty seventh. + public const int FieldCount = 7; + + public string ToWire() => string.Join( + StationRecord.FieldSeparator, + [ComputerName, Address, Port.ToString(CultureInfo.InvariantCulture), Version, Operator, VpnAdapter, ""]); + + /// Null for anything that is not a beacon. N1MM's own reader says so out + /// loud — it tells the operator that the other station is on an old + /// version — but a beacon is broadcast, so anything on the network can land + /// here and most of it is not worth a message. + public static StationBeacon? Read(string text) + { + // N1MM's own check: XML on this port is the port-12060 contact + // broadcast pointed at the wrong place + if (text.Contains("xml version", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + string[] parts = text.Split(StationRecord.FieldSeparator); + if (parts.Length != FieldCount) + { + return null; + } + return new StationBeacon( + parts[0], + parts[1], + int.TryParse(parts[2], CultureInfo.InvariantCulture, out int port) ? port : DefaultPort, + parts[3], + parts[4], + parts[5]); + } +} diff --git a/src/Nonemm.Network/StationLink.cs b/src/Nonemm.Network/StationLink.cs new file mode 100644 index 0000000..4e74394 --- /dev/null +++ b/src/Nonemm.Network/StationLink.cs @@ -0,0 +1,555 @@ +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); + + /// 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 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 = []; + + 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; + } + + /// 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; + + 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; + } + 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. It goes out whenever the radio moves or the + /// operator turns run on or off, and it is what the other stations show and + /// what the band-change rule counts. + public Task SendBandAsync( + Frequency frequency, + Mode mode, + bool running, + int radioNumber, + CancellationToken cancellation = default) + { + mine.Frequency = frequency; + mine.Mode = mode; + mine.IsRunning = running; + mine.RadioNumber = radioNumber; + StationsChanged?.Invoke(this, EventArgs.Empty); + return SendAsync( + StationMessages.OnBand(frequency, mode, running, radioNumber, StationNumber, computerName), + cancellation); + } + + public Task SendTransmittingAsync( + bool transmitting, + int radioNumber, + CancellationToken cancellation = default) + { + mine.IsTransmitting = transmitting; + return SendAsync( + StationMessages.Transmitting(transmitting, radioNumber, 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); + } + + public void Dispose() + { + 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); + 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) + { + 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 (!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); + } + + 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++; + switch (record.Type) + { + case "IAM": + station.StationNumber = record.Number(0); + break; + case "BANDMAP": + station.Frequency = Frequency.FromKilohertz(record.Decimal(0)); + station.Mode = Modes.Parse(record.Field(1)); + station.IsRunning = record.Flag(2); + station.RadioNumber = Math.Max(1, record.Number(3)); + break; + case "XMIT": + station.IsTransmitting = record.Flag(0); + station.RadioNumber = Math.Max(1, record.Number(1)); + break; + case "PASSFREQ": + station.PassFrequency = Frequency.FromKilohertz(record.Decimal(0)); + station.PassCall = record.Field(1); + break; + case "ECHOREQ": + _ = SendAsync(StationMessages.Echo(record, StationNumber, computerName)); + break; + case "ECHO": + station.EchoTime = DateTime.UtcNow - echoSentAt; + 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); + } + + /// 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 ""; + } + } +} diff --git a/src/Nonemm.Network/StationMessages.cs b/src/Nonemm.Network/StationMessages.cs new file mode 100644 index 0000000..0cf4e03 --- /dev/null +++ b/src/Nonemm.Network/StationMessages.cs @@ -0,0 +1,169 @@ +using System.Globalization; +using Nonemm.Core; + +namespace Nonemm.Network; + +/// The messages two logging computers send each other, and what to do with one +/// that arrives. +/// +/// N1MM has around forty of these. The ones here are the ones a multi-operator +/// entry cannot run without: +/// +/// | Type | What it says | Read | Sent | +/// |---|---|---|---| +/// | `QSO` | a contact was logged | yes | yes | +/// | `ReEditQSO` | a contact was edited | yes | yes | +/// | `QSODELETE` | a contact was removed | yes | yes | +/// | `ReSyncQSO` | a contact again, in answer to a resync | yes | yes | +/// | `IAM` | which station number the sender has taken | yes | yes | +/// | `ECHOREQ` | are you there | yes | yes | +/// | `ECHO` | yes | yes | yes | +/// | `TALK` | a line of chat between operators | yes | yes | +/// | `PASSFREQ` | a frequency being passed to another radio | yes | yes | +/// | `XMIT` | a station started or stopped transmitting | yes | yes | +/// | `BANDMAP` | which band and mode a station is on | yes | yes | +/// +/// The rest — the score and sked windows, the log check, the spot lists, the +/// serial-number pool — are passed over. An unknown type is not an error: N1MM +/// adds them between versions, and a station that cannot read one is no worse +/// off than a station that has not been told. +public static class StationMessages +{ + /// A logged contact. Every field is N1MM's `QSOString`, with the timestamp + /// the contact had before this message in front of them; for a new contact + /// that is the same timestamp. + public static StationRecord Logged(Qso qso, int stationNumber, string computerName) => + Record("QSO", stationNumber, computerName, [StationRecord.Written(qso.TimestampUtc), .. QsoRecord.Write(qso)]); + + /// A contact that has been edited. The old timestamp and the old callsign + /// say which row to replace, because that pair is what N1MM keys a contact + /// on rather than an identifier of its own. + public static StationRecord Edited( + Qso qso, + string oldCall, + DateTime oldTimestampUtc, + int stationNumber, + string computerName) => + Record( + "ReEditQSO", + stationNumber, + computerName, + [StationRecord.Written(oldTimestampUtc), oldCall, .. QsoRecord.Write(qso)]); + + /// The same contact sent again because another station asked for it. It is + /// a separate type so the other end can tell a resync from a contact just + /// made and not count it twice in the rate window. + public static StationRecord Resynced(Qso qso, int stationNumber, string computerName) => + Record( + "ReSyncQSO", + stationNumber, + computerName, + [StationRecord.Written(qso.TimestampUtc), .. QsoRecord.Write(qso)]); + + public static StationRecord Deleted(Qso qso, int stationNumber, string computerName) => + Record( + "QSODELETE", + stationNumber, + computerName, + [StationRecord.Written(qso.TimestampUtc), qso.Call.Text, Text(qso.ContestNumber), qso.Id]); + + /// Which station number this computer has taken. N1MM numbers the stations + /// of an entry so a contact can say which position made it. + public static StationRecord IAm(int stationNumber, string computerName) => + Record("IAM", stationNumber, computerName, [Text(stationNumber)]); + + /// Are you there. N1MM sends the date and the time of day, and uses the + /// answer to show how long the round trip took. + public static StationRecord EchoRequest(int stationNumber, string computerName, DateTime now) => + Record( + "ECHOREQ", + stationNumber, + computerName, + [StationRecord.WrittenDate(now), now.ToString("HH:mm:ss", CultureInfo.InvariantCulture)]); + + /// The answer, carrying back what the request said so the asker can work + /// out the round trip without keeping anything. + public static StationRecord Echo(StationRecord request, int stationNumber, string computerName) => + Record("ECHO", stationNumber, computerName, [request.Field(0), request.Field(1)]); + + public static StationRecord Talk(string text, int stationNumber, string computerName) => + Record("TALK", stationNumber, computerName, [$"[{computerName}] {text}"]); + + /// A frequency handed to another radio, which is N1MM's pass. The + /// callsign is who is on it. + public static StationRecord PassFrequency( + Frequency frequency, + string call, + int stationNumber, + string computerName) => + Record( + "PASSFREQ", + stationNumber, + computerName, + [StationRecord.Written(frequency.Kilohertz), call]); + + /// A station started or stopped transmitting. The other stations of a + /// multi-single entry need this: two of them keying at once is one signal + /// too many. + public static StationRecord Transmitting( + bool transmitting, + int radioNumber, + int stationNumber, + string computerName) => + Record( + "XMIT", + stationNumber, + computerName, + [StationRecord.Written(transmitting), Text(radioNumber)]); + + /// Where a station is: the band and the mode it is on, and whether it is + /// running. This is what fills the band and Running columns of the network + /// status window, and what the band-change rule counts. + public static StationRecord OnBand( + Frequency frequency, + Mode mode, + bool running, + int radioNumber, + int stationNumber, + string computerName) => + Record( + "BANDMAP", + stationNumber, + computerName, + [ + StationRecord.Written(frequency.Kilohertz), + mode.Name, + StationRecord.Written(running), + Text(radioNumber), + ]); + + /// What a message that has arrived means, or null for one this program does + /// nothing with. + public static ContactUpdate? Read(StationRecord record) => record.Type switch + { + "QSO" or "RESYNCQSO" => new ContactLogged( + QsoRecord.Read(record, 1, record.ComputerName), + record.ComputerName), + "REEDITQSO" => new ContactReplaced( + QsoRecord.Read(record, 2, record.ComputerName), + record.Field(1), + record.Time(0), + record.ComputerName), + "QSODELETE" => new ContactDeleted( + record.Field(3), + record.Field(1), + record.Time(0), + record.Number(2), + record.ComputerName), + _ => null, + }; + + private static StationRecord Record( + string type, + int stationNumber, + string computerName, + IReadOnlyList fields) => + new(stationNumber, computerName, type, fields); + + private static string Text(int number) => number.ToString(CultureInfo.InvariantCulture); +} diff --git a/src/Nonemm.Network/StationRecord.cs b/src/Nonemm.Network/StationRecord.cs new file mode 100644 index 0000000..29529a7 --- /dev/null +++ b/src/Nonemm.Network/StationRecord.cs @@ -0,0 +1,149 @@ +using System.Globalization; +using System.Text; + +namespace Nonemm.Network; + +/// One message on the wire between two logging computers, and the frame around +/// it. +/// +/// This is not the XML on port 12060 that `ContactMessage` writes. That one is +/// N1MM talking to other programs — a spotting tool, a score poster. This is +/// N1MM talking to another copy of itself on port 12070, and the format is +/// different: fields separated by `%`, the whole message ended with `~`, and +/// the lot wrapped in `DATA__` and `__DATA`. +/// +/// A frame reads +/// +/// ``` +/// DATA__07%SHACK-PC%QSO%2026-09-03 12:34:56%DL1ABC%…~__DATA +/// ``` +/// +/// which is the sending station's number in two digits, its computer name, the +/// message type, and then as many fields as the type has. `%` and `~` cannot +/// appear in a field, so N1MM writes `!` in their place; this does the same, so +/// a comment with a per-cent sign in it arrives as N1MM would have sent it +/// rather than splitting the message in half. +/// +/// Both delimiters are needed. TCP hands over whatever has arrived, which is +/// half a message as often as two of them, so `~` says where a message ends; +/// `DATA__` and `__DATA` are N1MM's own and are kept because N1MM looks for +/// them. +public sealed record StationRecord(int StationNumber, string ComputerName, string Type, IReadOnlyList Fields) +{ + public const string FramePrefix = "DATA__"; + + public const string FrameSuffix = "__DATA"; + + /// What separates the fields, and what ends a message. + public const char FieldSeparator = '%'; + + public const char MessageEnd = '~'; + + /// What N1MM puts in place of a delimiter that turns up inside a field. + public const char Escape = '!'; + + private const string TimeFormat = "yyyy-MM-dd HH:mm:ss"; + + private const string DateFormat = "yyyy-MM-dd"; + + /// The message as it goes on the wire, frame and all. + public string ToWire() + { + StringBuilder text = new(); + text.Append(FramePrefix); + text.Append(StationNumber.ToString("00", CultureInfo.InvariantCulture)); + text.Append(FieldSeparator); + text.Append(Clean(ComputerName.ToUpperInvariant())); + text.Append(FieldSeparator); + text.Append(Clean(Type)); + text.Append(FieldSeparator); + foreach (string field in Fields) + { + text.Append(Clean(field)); + text.Append(FieldSeparator); + } + text.Append(MessageEnd); + text.Append(FrameSuffix); + return text.ToString(); + } + + /// One message read out of `text`, which is whatever has arrived so far. + /// Null while no whole message is there yet; the caller keeps the rest and + /// adds what arrives next to it. + /// + /// The frame markers are taken off wherever they stand, because a reader + /// that has fallen behind holds several frames at once and the `__DATA` + /// that ends one sits in front of the `DATA__` that starts the next. + public static StationRecord? Read(ref string text) + { + int end = text.IndexOf(MessageEnd, StringComparison.Ordinal); + if (end < 0) + { + return null; + } + string message = text[..end]; + text = text[(end + 1)..]; + message = message + .Replace(FramePrefix, "", StringComparison.Ordinal) + .Replace(FrameSuffix, "", StringComparison.Ordinal); + string[] parts = message.Split(FieldSeparator); + // the station number, the computer name and the type, and then the + // trailing separator leaves one empty field on the end + if (parts.Length < 4) + { + return null; + } + return new StationRecord( + int.TryParse(parts[0], CultureInfo.InvariantCulture, out int number) ? number : 0, + parts[1], + parts[2].ToUpperInvariant(), + parts[3..^1]); + } + + /// The field at `at`, or empty when the message is shorter than that. A + /// station running another version sends fewer fields than this one reads, + /// and a short message is worth more than no message. + public string Field(int at) => at >= 0 && at < Fields.Count ? Fields[at] : ""; + + public int Number(int at) => + int.TryParse(Field(at), CultureInfo.InvariantCulture, out int value) ? value : 0; + + /// A field written as N1MM's `uNum`, which is two decimal places with a + /// dot whatever the machine's own separator is. + public double Decimal(int at) => + double.TryParse(Field(at), NumberStyles.Float, CultureInfo.InvariantCulture, out double value) + ? value + : 0; + + /// N1MM writes a boolean as Visual Basic prints one, which is `True` or + /// `False`. A number is read as well, because its own log holds -1 and 0 + /// for the same thing. + public bool Flag(int at) => + Field(at).Trim() is { Length: > 0 } text + && (text.Equals("True", StringComparison.OrdinalIgnoreCase) || Number(at) != 0); + + public DateTime Time(int at) => + DateTime.TryParseExact( + Field(at), + TimeFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out DateTime value) + ? value + : default; + + public static string Written(DateTime time) => + time.ToString(TimeFormat, CultureInfo.InvariantCulture); + + public static string WrittenDate(DateTime time) => + time.ToString(DateFormat, CultureInfo.InvariantCulture); + + public static string Written(double number) => + number.ToString("0.00", CultureInfo.InvariantCulture); + + public static string Written(bool flag) => flag ? "True" : "False"; + + /// A field with the delimiters taken out of it, which is what N1MM sends. + private static string Clean(string field) => + field.Replace(FieldSeparator, Escape).Replace(MessageEnd, Escape); +} diff --git a/tests/Nonemm.Network.Tests/StationLinkTests.cs b/tests/Nonemm.Network.Tests/StationLinkTests.cs new file mode 100644 index 0000000..b3b8e65 --- /dev/null +++ b/tests/Nonemm.Network.Tests/StationLinkTests.cs @@ -0,0 +1,188 @@ +using System.Net; +using System.Net.Sockets; +using Nonemm.Core; + +namespace Nonemm.Network.Tests; + +/// Two links talking to each other over loopback. The stations are named by +/// hand rather than found by a beacon: a broadcast test would depend on the +/// machine's interfaces, and what is being tested here is what happens after +/// two stations have found each other. +public class StationLinkTests +{ + private const string Version = "1.0.11364"; + + private static readonly TimeSpan Patience = TimeSpan.FromSeconds(10); + + [Fact] + public async Task AContactReachesTheOtherStation() + { + (int onePort, int twoPort) = SparePorts(); + using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort); + using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort); + one.Start(); + two.Start(); + ContactUpdate? arrived = null; + two.UpdateArrived += (_, update) => arrived = update; + one.AddStation("MULT-PC", "127.0.0.1", twoPort); + await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true })); + + await one.SendLoggedAsync(Contact()); + + await WaitForAsync(() => arrived is not null); + ContactLogged logged = Assert.IsType(arrived); + Assert.Equal("G3XYZ", logged.Qso.Call.Text); + Assert.Equal("RUN-PC", logged.Qso.StationName); + Assert.Equal(1, logged.Qso.NetworkedComputerNumber); + } + + /// The first message on a new connection says which station of the entry + /// the sender is, so the other end can show its number. + [Fact] + public async Task ConnectingSaysWhichStationNumberThisIs() + { + (int onePort, int twoPort) = SparePorts(); + using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort); + using StationLink two = new("MULT-PC", Version, stationNumber: 7, port: twoPort); + one.Start(); + two.Start(); + one.AddStation("MULT-PC", "127.0.0.1", twoPort); + await WaitForAsync(() => + two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.StationNumber == 1); + + NetworkedStation? seen = two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC"); + Assert.Equal(1, seen?.StationNumber); + Assert.Equal("IAM", seen?.LastMessage); + } + + /// The band a station is on is what the status window shows and what the + /// band-change rule counts. + [Fact] + public async Task WhereAStationIsReachesTheOthers() + { + (int onePort, int twoPort) = SparePorts(); + using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort); + using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort); + one.Start(); + two.Start(); + one.AddStation("MULT-PC", "127.0.0.1", twoPort); + await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true })); + + await one.SendBandAsync(Frequency.FromKilohertz(21_025), Modes.Cw, running: true, radioNumber: 1); + + await WaitForAsync(() => + two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.Band?.Name == "15M"); + NetworkedStation? seen = two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC"); + Assert.Equal("15M", seen?.Band?.Name); + Assert.Equal("CW", seen?.Mode?.Name); + Assert.True(seen?.IsRunning); + } + + [Fact] + public async Task ChatReachesTheOtherOperator() + { + (int onePort, int twoPort) = SparePorts(); + using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort); + using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort); + one.Start(); + two.Start(); + string? said = null; + two.TalkArrived += (_, text) => said = text; + one.AddStation("MULT-PC", "127.0.0.1", twoPort); + await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true })); + + await one.SendTalkAsync("qsy 20"); + + await WaitForAsync(() => said is not null); + Assert.Equal("[RUN-PC] qsy 20", said); + } + + /// An echo request is answered without anybody asking, which is how N1MM + /// measures the round trip to each station. + [Fact] + public async Task AnEchoRequestIsAnswered() + { + (int onePort, int twoPort) = SparePorts(); + using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort); + using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort); + one.Start(); + two.Start(); + one.AddStation("MULT-PC", "127.0.0.1", twoPort); + two.AddStation("RUN-PC", "127.0.0.1", onePort); + await WaitForAsync(() => + one.Stations.Any(s => s is { IsMine: false, IsConnected: true }) + && two.Stations.Any(s => s is { IsMine: false, IsConnected: true })); + + await one.SendEchoRequestAsync(); + + await WaitForAsync(() => + one.Stations.FirstOrDefault(s => s.ComputerName == "MULT-PC")?.EchoTime is not null); + Assert.NotNull(one.Stations.FirstOrDefault(s => s.ComputerName == "MULT-PC")?.EchoTime); + } + + /// This computer is in its own list, the way N1MM shows it, so an operator + /// can read its station number and version off the same window. + [Fact] + public void ThisComputerIsInItsOwnStationList() + { + using StationLink link = new("RUN-PC", Version, stationNumber: 3, port: SparePorts().One); + + NetworkedStation mine = Assert.Single(link.Stations); + + Assert.True(mine.IsMine); + Assert.Equal("RUN-PC", mine.ComputerName); + Assert.Equal(3, mine.StationNumber); + Assert.Equal(Version, mine.Version); + } + + /// A message that reached nobody says so, rather than looking as though it + /// went out. + [Fact] + public async Task SendingWithNoStationsConnectedReachesNobody() + { + using StationLink link = new("RUN-PC", Version, port: SparePorts().One); + link.Start(); + + Assert.Equal(0, await link.SendLoggedAsync(Contact())); + } + + private static async Task WaitForAsync(Func ready) + { + DateTime giveUp = DateTime.UtcNow + Patience; + while (!ready() && DateTime.UtcNow < giveUp) + { + await Task.Delay(5); + } + } + + /// Two ports nothing else is on, so the tests do not fight each other or + /// the N1MM the machine may be running. + /// + /// Both are taken before either is let go. Asking twice in a row does not + /// work: the second ask often gets the port the first one has just given + /// back, and two links on one port leaves the second without a listener. + private static (int One, int Two) SparePorts() + { + TcpListener first = new(IPAddress.Loopback, 0); + TcpListener second = new(IPAddress.Loopback, 0); + first.Start(); + second.Start(); + int one = ((IPEndPoint)first.LocalEndpoint).Port; + int two = ((IPEndPoint)second.LocalEndpoint).Port; + first.Stop(); + second.Stop(); + return (one, two); + } + + private static Qso Contact() => new() + { + Id = Qso.NewId(), + TimestampUtc = new DateTime(2026, 9, 3, 12, 34, 56, DateTimeKind.Utc), + Call = Callsign.Parse("G3XYZ"), + Frequency = Frequency.FromKilohertz(14_025), + Mode = Modes.Cw, + ContestName = "CQWW", + ContestNumber = 2, + SentNumber = 41, + }; +} diff --git a/tests/Nonemm.Network.Tests/StationRecordTests.cs b/tests/Nonemm.Network.Tests/StationRecordTests.cs new file mode 100644 index 0000000..417648d --- /dev/null +++ b/tests/Nonemm.Network.Tests/StationRecordTests.cs @@ -0,0 +1,271 @@ +using Nonemm.Core; + +namespace Nonemm.Network.Tests; + +/// The computer-to-computer protocol on port 12070. It is N1MM's own, so the +/// tests are about the bytes: a field in the wrong place is a contact with the +/// callsign in the comment. +public class StationRecordTests +{ + [Fact] + public void AMessageIsFramedTheWayN1MmFramesIt() + { + StationRecord record = new(7, "shack-pc", "IAM", ["7"]); + + Assert.Equal("DATA__07%SHACK-PC%IAM%7%~__DATA", record.ToWire()); + } + + [Fact] + public void AMessageReadsBackTheWayItWasWritten() + { + string wire = new StationRecord(3, "SHACK-PC", "TALK", ["hello", "there"]).ToWire(); + + StationRecord? read = StationRecord.Read(ref wire); + + Assert.NotNull(read); + Assert.Equal(3, read.StationNumber); + Assert.Equal("SHACK-PC", read.ComputerName); + Assert.Equal("TALK", read.Type); + Assert.Equal(["hello", "there"], read.Fields); + } + + /// TCP hands over whatever has arrived, which is half a message as often as + /// a whole one. + [Fact] + public void HalfAMessageIsHeldUntilTheRestArrives() + { + string whole = new StationRecord(1, "PC", "IAM", ["1"]).ToWire(); + string text = whole[..10]; + + Assert.Null(StationRecord.Read(ref text)); + + text += whole[10..]; + + Assert.NotNull(StationRecord.Read(ref text)); + } + + [Fact] + public void TwoMessagesInOneReadAreBothFound() + { + string text = new StationRecord(1, "PC", "IAM", ["1"]).ToWire() + + new StationRecord(2, "OTHER", "IAM", ["2"]).ToWire(); + + StationRecord? first = StationRecord.Read(ref text); + StationRecord? second = StationRecord.Read(ref text); + + Assert.Equal("PC", first?.ComputerName); + Assert.Equal("OTHER", second?.ComputerName); + Assert.Null(StationRecord.Read(ref text)); + } + + /// N1MM writes `!` in place of a delimiter that turns up in a field, so a + /// comment with a per-cent sign in it does not split the message. + [Fact] + public void ADelimiterInsideAFieldIsReplaced() + { + StationRecord record = new(1, "PC", "TALK", ["100% sure~ok"]); + + string wire = record.ToWire(); + StationRecord? read = StationRecord.Read(ref wire); + + Assert.Equal("100! sure!ok", read?.Field(0)); + } + + [Fact] + public void AFieldPastTheEndOfTheMessageIsEmpty() + { + StationRecord record = new(1, "PC", "IAM", ["1"]); + + Assert.Equal("", record.Field(9)); + Assert.Equal(0, record.Number(9)); + Assert.False(record.Flag(9)); + } + + /// N1MM writes a boolean as Visual Basic prints one. Its own log holds -1 + /// and 0 for the same thing, so both are read. + [Theory] + [InlineData("True", true)] + [InlineData("False", false)] + [InlineData("-1", true)] + [InlineData("1", true)] + [InlineData("0", false)] + [InlineData("", false)] + public void BooleansAreReadTheWayN1MmWritesThem(string field, bool expected) + { + StationRecord record = new(1, "PC", "XMIT", [field]); + + Assert.Equal(expected, record.Flag(0)); + } + + [Fact] + public void ABeaconCarriesTheSixFieldsN1MmCounts() + { + StationBeacon beacon = new("SHACK-PC", "192.168.1.5", 12070, "1.0.11364", "OM3KFF"); + + Assert.Equal("SHACK-PC%192.168.1.5%12070%1.0.11364%OM3KFF%%", beacon.ToWire()); + Assert.Equal(beacon, StationBeacon.Read(beacon.ToWire())); + } + + /// N1MM splits the beacon on `%` and refuses anything that is not seven + /// long, which is how it turns away a station on an older version. + [Fact] + public void ABeaconWithTheWrongNumberOfFieldsIsNotABeacon() + { + Assert.Null(StationBeacon.Read("SHACK-PC%192.168.1.5%12070")); + } + + /// The port-12060 contact broadcast pointed at the wrong port. N1MM says + /// so out loud; this drops it. + [Fact] + public void XmlOnTheBeaconPortIsNotABeacon() + { + Assert.Null(StationBeacon.Read("")); + } + + [Fact] + public void ALoggedContactRoundTripsThroughTheWire() + { + Qso qso = Contact(); + + string wire = StationMessages.Logged(qso, 4, "SHACK-PC").ToWire(); + StationRecord? record = StationRecord.Read(ref wire); + ContactUpdate? update = StationMessages.Read(record!); + + ContactLogged logged = Assert.IsType(update); + Assert.Equal("SHACK-PC", logged.StationName); + Assert.Equal(qso.Call.Text, logged.Qso.Call.Text); + Assert.Equal(qso.TimestampUtc, logged.Qso.TimestampUtc); + Assert.Equal(qso.Frequency.Hertz, logged.Qso.Frequency.Hertz); + Assert.Equal(qso.Mode.Name, logged.Qso.Mode.Name); + Assert.Equal(qso.ContestName, logged.Qso.ContestName); + Assert.Equal(qso.SentNumber, logged.Qso.SentNumber); + Assert.Equal(qso.ReceivedNumber, logged.Qso.ReceivedNumber); + Assert.Equal(qso.Zone, logged.Qso.Zone); + Assert.Equal(qso.Points, logged.Qso.Points); + Assert.True(logged.Qso.IsMultiplier1); + Assert.False(logged.Qso.IsMultiplier2); + Assert.Equal(qso.Operator, logged.Qso.Operator); + Assert.Equal(qso.ContestNumber, logged.Qso.ContestNumber); + Assert.Equal(qso.Continent, logged.Qso.Continent); + Assert.Equal(4, logged.Qso.NetworkedComputerNumber); + } + + /// A contact that arrived over the network was made somewhere else, which + /// is what the log window colours a row on. + [Fact] + public void AContactFromAnotherStationIsNotOriginal() + { + string wire = StationMessages.Logged(Contact(), 4, "SHACK-PC").ToWire(); + StationRecord? record = StationRecord.Read(ref wire); + + ContactLogged logged = Assert.IsType(StationMessages.Read(record!)); + + Assert.False(logged.Qso.IsOriginal); + Assert.Equal("SHACK-PC", logged.Qso.StationName); + } + + /// An edit carries the old callsign and the old time in front of the + /// contact, because that pair is what N1MM keys a contact on. + [Fact] + public void AnEditSaysWhichRowToReplace() + { + Qso qso = Contact() with { Call = Callsign.Parse("DL1ABC") }; + DateTime was = qso.TimestampUtc.AddMinutes(-3); + + string wire = StationMessages.Edited(qso, "DL1AB", was, 4, "SHACK-PC").ToWire(); + StationRecord? record = StationRecord.Read(ref wire); + + ContactReplaced replaced = Assert.IsType(StationMessages.Read(record!)); + + Assert.Equal("DL1AB", replaced.OldCall); + Assert.Equal(was, replaced.OldTimestampUtc); + Assert.Equal("DL1ABC", replaced.Qso.Call.Text); + } + + [Fact] + public void ADeleteNamesTheContactAndTheContest() + { + Qso qso = Contact(); + + string wire = StationMessages.Deleted(qso, 4, "SHACK-PC").ToWire(); + StationRecord? record = StationRecord.Read(ref wire); + + ContactDeleted deleted = Assert.IsType(StationMessages.Read(record!)); + + Assert.Equal(qso.Id, deleted.Id); + Assert.Equal(qso.Call.Text, deleted.Call); + Assert.Equal(qso.TimestampUtc, deleted.TimestampUtc); + Assert.Equal(qso.ContestNumber, deleted.ContestNumber); + } + + /// A resync is the same contact sent again, so it means the same thing. + [Fact] + public void AResyncedContactIsReadAsALoggedOne() + { + string wire = StationMessages.Resynced(Contact(), 4, "SHACK-PC").ToWire(); + StationRecord? record = StationRecord.Read(ref wire); + + Assert.IsType(StationMessages.Read(record!)); + } + + /// N1MM adds message types between versions. One this program does not + /// know is passed over rather than treated as a fault. + [Fact] + public void AMessageTypeThisProgramDoesNotKnowIsPassedOver() + { + Assert.Null(StationMessages.Read(new StationRecord(1, "PC", "SKEDD", ["something"]))); + } + + /// The frequency the other station transmits on is only kept when it is + /// not the one it listens on: a contact in split is the exception, not the + /// rule. + [Fact] + public void OneFrequencyForBothMeansNoSplit() + { + string wire = StationMessages.Logged(Contact(), 1, "PC").ToWire(); + StationRecord? record = StationRecord.Read(ref wire); + + ContactLogged logged = Assert.IsType(StationMessages.Read(record!)); + + Assert.Equal(0, logged.Qso.QsxFrequency.Hertz); + } + + [Fact] + public void ASplitContactKeepsBothFrequencies() + { + Qso qso = Contact() with { QsxFrequency = Frequency.FromKilohertz(14_205) }; + + string wire = StationMessages.Logged(qso, 1, "PC").ToWire(); + StationRecord? record = StationRecord.Read(ref wire); + + ContactLogged logged = Assert.IsType(StationMessages.Read(record!)); + + Assert.Equal(14_205_000, logged.Qso.QsxFrequency.Hertz); + Assert.Equal(14_025_000, logged.Qso.Frequency.Hertz); + } + + private static Qso Contact() => new() + { + Id = Qso.NewId(), + TimestampUtc = new DateTime(2026, 9, 3, 12, 34, 56, DateTimeKind.Utc), + Call = Callsign.Parse("G3XYZ"), + Frequency = Frequency.FromKilohertz(14_025), + Mode = Modes.Cw, + ContestName = "CQWW", + ContestNumber = 2, + SentReport = "599", + ReceivedReport = "599", + SentNumber = 41, + ReceivedNumber = 17, + Zone = 14, + Points = 3, + IsMultiplier1 = true, + Operator = "OM3KFF", + RadioNumber = 2, + Continent = "EU", + CountryPrefix = "G", + StationPrefix = "G", + WpxPrefix = "G3", + Comment = "good sig", + }; +}