Speak telnet properly to the cluster node

The old client read the socket with a StreamReader, so the option negotiation a
node sends on connect landed in the text as control bytes and made a mess of
the first lines. TelnetStream now answers it: it agrees to ECHO and
SUPPRESS-GO-AHEAD, refuses everything else, swallows subnegotiations, and
tracks what it already answered so the two ends do not reply to each other for
ever. A command split across two reads is still understood, because a socket
hands over whatever has turned up rather than whole messages.

LineAssembler keeps the tail that has no line ending yet. Nodes write their
login prompt as "login: " with nothing after it, so the old ReadLineAsync sat
waiting for a line that never came and only got through because the greeting
happened to mention a matching word.

Login now follows N1MM: it looks for LOGON, ENTER CALL, LOG IN and the rest,
and sends the callsign anyway after ten seconds if no prompt turns up. A
password is sent when the node asks for one and the operator configured one.
The commands go out after that, not straight after the call. A connection that
has heard nothing for four minutes gets a blank line so the node does not drop
it as idle.

The old prompt check matched "call" anywhere in a line, which any spot comment
could trigger.

SendSpotAsync sends the node's dx command. Alt+P, or Edit / Spot It, spots the
call being typed at the current frequency, or the last contact logged when
nothing is typed, and puts it on our own bandmap without waiting for it to come
back round from the node.

The spot parser now takes a line with no colon after the spotter, and finds the
time when DXSpider has put the spotter's grid after it.

The cluster tests run over a real socket against a node fake that sends the
negotiation, the prompt and spot lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 15:46:19 +00:00
parent b355b9b6bf
commit 2b8033ccdd
19 changed files with 918 additions and 66 deletions

View File

@@ -0,0 +1,135 @@
using System.Text;
using Nonemm.Spotting.Telnet;
namespace Nonemm.Spotting.Tests;
public class TelnetStreamTests
{
private const byte Iac = 255;
private const byte SubnegotiationEnd = 240;
private const byte SubnegotiationBegin = 250;
private const byte Will = 251;
private const byte Wont = 252;
private const byte Do = 253;
private const byte Dont = 254;
private const byte Echo = 1;
private const byte SuppressGoAhead = 3;
private const byte TerminalType = 24;
private static byte[] Text(string text) => Encoding.Latin1.GetBytes(text);
private static async Task<(string Read, IReadOnlyList<byte> Written)> Run(params byte[][] blocks)
{
FakeTelnetStream fake = new(blocks);
TelnetStream telnet = new(fake);
StringBuilder read = new();
while (await telnet.ReadAsync() is { } text)
{
read.Append(text);
}
return (read.ToString(), fake.Written);
}
[Fact]
public async Task PlainTextComesBackUnchanged()
{
(string read, _) = await Run(Text("DX de W3LPL: 14025.0 JA1XYZ\r\n"));
Assert.Equal("DX de W3LPL: 14025.0 JA1XYZ\r\n", read);
}
/// A client that leaves the negotiation unanswered gets these bytes mixed
/// into the first line it reads.
[Fact]
public async Task TheNegotiationIsAnsweredAndKeptOutOfTheText()
{
(string read, IReadOnlyList<byte> written) = await Run(
[Iac, Do, TerminalType, .. Text("login: ")]);
Assert.Equal("login: ", read);
Assert.Equal([Iac, Wont, TerminalType], written);
}
[Fact]
public async Task AnOptionWeSupportIsAgreedTo()
{
(_, IReadOnlyList<byte> written) = await Run([Iac, Will, SuppressGoAhead]);
Assert.Equal([Iac, Do, SuppressGoAhead], written);
}
[Fact]
public async Task AnOptionWeDoNotSupportIsRefused()
{
(_, IReadOnlyList<byte> written) = await Run([Iac, Will, TerminalType]);
Assert.Equal([Iac, Dont, TerminalType], written);
}
/// Answering an option that is already on would have the two ends replying
/// to each other for as long as the connection lasts.
[Fact]
public async Task TheSameOptionIsNotAgreedToTwice()
{
(_, IReadOnlyList<byte> written) = await Run(
[Iac, Will, Echo],
[Iac, Will, Echo]);
Assert.Equal([Iac, Do, Echo], written);
}
[Fact]
public async Task AnUnwantedOptionIsNotRefusedAgainAfterItIsOff()
{
(_, IReadOnlyList<byte> written) = await Run([Iac, Wont, Echo]);
Assert.Empty(written);
}
[Fact]
public async Task ASubnegotiationIsSwallowedWhole()
{
(string read, _) = await Run(
[.. Text("a"), Iac, SubnegotiationBegin, TerminalType, 1, 2, 3, Iac, SubnegotiationEnd, .. Text("b")]);
Assert.Equal("ab", read);
}
/// A command can arrive split over two reads, because a socket hands over
/// whatever has turned up rather than whole messages.
[Fact]
public async Task ACommandSplitAcrossTwoBlocksIsStillUnderstood()
{
(string read, IReadOnlyList<byte> written) = await Run(
[.. Text("ab"), Iac],
[Do, TerminalType, .. Text("cd")]);
Assert.Equal("abcd", read);
Assert.Equal([Iac, Wont, TerminalType], written);
}
[Fact]
public async Task ADoubledIacIsOneByteOfText()
{
(string read, _) = await Run([.. Text("a"), Iac, Iac, .. Text("b")]);
Assert.Equal("aÿb", read);
}
[Fact]
public async Task TheNullOfABareCarriageReturnIsDropped()
{
(string read, _) = await Run([.. Text("a\r"), 0, .. Text("b")]);
Assert.Equal("a\rb", read);
}
[Fact]
public async Task ALineGoesOutWithTheEndingTelnetAsksFor()
{
FakeTelnetStream fake = new();
await new TelnetStream(fake).WriteLineAsync("dx 14025.0 JA1XYZ");
Assert.Equal(Text("dx 14025.0 JA1XYZ\r\n"), fake.Written);
}
}