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:
188
tests/Nonemm.Network.Tests/StationLinkTests.cs
Normal file
188
tests/Nonemm.Network.Tests/StationLinkTests.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user