Talk to the other logging computers on N1MM's own port

N1MM has two networks and this program had only one of them. Port 12060
carries XML to other programs — a spot tool, a score poster — and
`StationNetwork` already writes that. Computer to computer, N1MM uses
port 12070 and a different protocol, which is what this adds.

The wire format, from `MultiOpManager` and `MultiStation`:

    DATA__07%SHACK-PC%QSO%2026-09-03 12:34:56%DL1ABC%…~__DATA

the sending station's number, its computer name, the message type and
the fields of that type. `%` and `~` cannot appear in a field, so N1MM
writes `!` in their place. `StationRecord` reads and writes that, and
holds what has arrived until a `~` says a message is whole: TCP hands
over half a message as often as two.

Discovery is a UDP broadcast to the same port, six fields wide.
**N1MM refuses a station whose version is not its own** — it says
"Software versions must match" and drops it — so the version this
program broadcasts is a setting rather than its own version, and a
station that says something else is kept in the list with `Refused` set
so the operator can see why nothing is arriving.

`StationLink` runs both sockets, opens a connection to every station it
hears, and reports what arrives. `QsoRecord` is the thirty-five fields
of a contact in N1MM's order, from `MultiOpManager.cs:1007`; points and
multiplier flags are read but the log works them out again from the
rules, so two stations cannot disagree about a score. `AddStation` names
a station by hand, for a network where a broadcast does not reach.

A station can also be named rather than heard, and one that is connected
may say nothing for a while, so `NetworkedStation` is heard-from as soon
as it exists. Left at nothing, the sweep that drops quiet stations read
every new station as quiet since the beginning of time and closed the
connection the moment it opened.

Tested as bytes and over loopback. Nothing has talked to a real N1MM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
2026-09-03 15:19:03 +00:00
parent 9f635550bd
commit c0e1bc1e1b
8 changed files with 1643 additions and 0 deletions

View File

@@ -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; } = "";
}

View File

@@ -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<string> 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);
}

View File

@@ -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]);
}
}

View File

@@ -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<string, NetworkedStation> stations = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, TcpClient> writers = new(StringComparer.OrdinalIgnoreCase);
private readonly NetworkedStation mine;
/// When the last echo request went out, which is what the round trip is
/// measured against.
private DateTime echoSentAt;
private UdpClient? beacons;
private TcpListener? listener;
private readonly List<Task> loops = [];
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<NetworkedStation> Stations => stations.Values.ToList();
/// A contact another station logged, edited or deleted.
public event EventHandler<ContactUpdate>? UpdateArrived;
/// A line of chat from another operator.
public event EventHandler<string>? TalkArrived;
/// Anything about the list of stations changed: one joined, one went quiet,
/// one moved band. The status window redraws on this.
public event EventHandler? StationsChanged;
public event EventHandler<string>? Failed;
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<int> SendAsync(StationRecord record, CancellationToken cancellation = default)
{
byte[] message = Encoding.UTF8.GetBytes(record.ToWire());
int reached = 0;
foreach ((string name, TcpClient writer) in writers)
{
try
{
await writer.GetStream().WriteAsync(message, cancellation).ConfigureAwait(false);
reached++;
if (stations.TryGetValue(name, out NetworkedStation? station))
{
station.Sent++;
}
}
catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
{
Drop(name, e.Message);
}
}
if (reached > 0)
{
mine.Sent += reached;
StationsChanged?.Invoke(this, EventArgs.Empty);
}
return reached;
}
/// A message to one station. Returns false when it could not be written,
/// which drops the connection: the next beacon opens a new one.
public async Task<bool> SendToAsync(
NetworkedStation station,
StationRecord record,
CancellationToken cancellation = default)
{
if (!writers.TryGetValue(station.ComputerName, out TcpClient? writer))
{
return false;
}
try
{
await writer
.GetStream()
.WriteAsync(Encoding.UTF8.GetBytes(record.ToWire()), cancellation)
.ConfigureAwait(false);
}
catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
{
Drop(station.ComputerName, e.Message);
return false;
}
station.Sent++;
mine.Sent++;
StationsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
public Task<int> SendLoggedAsync(Qso qso, CancellationToken cancellation = default) =>
SendAsync(StationMessages.Logged(qso, StationNumber, computerName), cancellation);
public Task<int> SendEditedAsync(
Qso qso,
string oldCall,
DateTime oldTimestampUtc,
CancellationToken cancellation = default) =>
SendAsync(
StationMessages.Edited(qso, oldCall, oldTimestampUtc, StationNumber, computerName),
cancellation);
public Task<int> SendDeletedAsync(Qso qso, CancellationToken cancellation = default) =>
SendAsync(StationMessages.Deleted(qso, StationNumber, computerName), cancellation);
public Task<int> SendTalkAsync(string text, CancellationToken cancellation = default) =>
SendAsync(StationMessages.Talk(text, StationNumber, computerName), cancellation);
/// Says where this station is. 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<int> 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<int> 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<int> 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 "";
}
}
}

View File

@@ -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<string> fields) =>
new(stationNumber, computerName, type, fields);
private static string Text(int number) => number.ToString(CultureInfo.InvariantCulture);
}

View File

@@ -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<string> 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);
}

View File

@@ -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<ContactLogged>(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<bool> 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,
};
}

View File

@@ -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("<?xml version=\"1.0\"?><contactinfo />"));
}
[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<ContactLogged>(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<ContactLogged>(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<ContactReplaced>(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<ContactDeleted>(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<ContactLogged>(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<ContactLogged>(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<ContactLogged>(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",
};
}