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,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",
};
}