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:
@@ -1,5 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Spotting.Telnet;
|
||||
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
@@ -8,30 +10,54 @@ namespace Nonemm.Spotting;
|
||||
/// the node's business and its replies are for the operator to read.
|
||||
public sealed class ClusterClient : IDisposable
|
||||
{
|
||||
/// N1MM waits this long for a login prompt and then sends the call anyway.
|
||||
private static readonly TimeSpan LoginDeadline = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// How long to wait for a password prompt before deciding there is none.
|
||||
private static readonly TimeSpan PasswordDeadline = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// Nodes drop a connection that has said nothing for a quarter of an hour.
|
||||
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromMinutes(4);
|
||||
|
||||
private readonly string host;
|
||||
private readonly int port;
|
||||
private readonly string callsign;
|
||||
private readonly string password;
|
||||
private readonly IReadOnlyList<string> commandsAfterLogin;
|
||||
private readonly TimeSpan retryInterval;
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
private readonly LineAssembler lines = new();
|
||||
|
||||
private TcpClient? client;
|
||||
private StreamWriter? writer;
|
||||
private TelnetStream? telnet;
|
||||
private Task? loop;
|
||||
private Login login = Login.WaitingForCallsign;
|
||||
private DateTime deadline;
|
||||
private DateTime lastHeard;
|
||||
|
||||
public ClusterClient(
|
||||
string host,
|
||||
int port,
|
||||
string callsign,
|
||||
IReadOnlyList<string>? commandsAfterLogin = null,
|
||||
string password = "",
|
||||
TimeSpan? retryInterval = null)
|
||||
{
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.callsign = callsign;
|
||||
this.password = password;
|
||||
this.commandsAfterLogin = commandsAfterLogin ?? [];
|
||||
this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
private enum Login
|
||||
{
|
||||
WaitingForCallsign,
|
||||
WaitingForPassword,
|
||||
Done,
|
||||
}
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public event EventHandler<Spot>? SpotArrived;
|
||||
@@ -44,13 +70,13 @@ public sealed class ClusterClient : IDisposable
|
||||
|
||||
public async Task SendAsync(string line, CancellationToken cancellation = default)
|
||||
{
|
||||
if (writer is null)
|
||||
if (telnet is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
await writer.WriteAsync(line + "\r\n").ConfigureAwait(false);
|
||||
await telnet.WriteLineAsync(line, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
@@ -58,6 +84,18 @@ public sealed class ClusterClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts a station on the cluster for everyone else to see. The node takes
|
||||
/// the frequency in kilohertz.
|
||||
public Task SendSpotAsync(
|
||||
Frequency frequency,
|
||||
Callsign call,
|
||||
string comment = "",
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
string kilohertz = frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture);
|
||||
return SendAsync($"dx {kilohertz} {call.Text} {comment}".TrimEnd(), cancellation);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
stopping.Cancel();
|
||||
@@ -91,43 +129,129 @@ public sealed class ClusterClient : IDisposable
|
||||
client?.Dispose();
|
||||
client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, cancellation).ConfigureAwait(false);
|
||||
NetworkStream stream = client.GetStream();
|
||||
using StreamReader reader = new(stream, Encoding.Latin1);
|
||||
writer = new StreamWriter(stream, Encoding.Latin1) { AutoFlush = true };
|
||||
telnet = new TelnetStream(client.GetStream());
|
||||
lines.Clear();
|
||||
login = Login.WaitingForCallsign;
|
||||
deadline = DateTime.UtcNow + LoginDeadline;
|
||||
lastHeard = DateTime.UtcNow;
|
||||
SetConnected(true);
|
||||
|
||||
bool loggedIn = false;
|
||||
using CancellationTokenSource session =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||
Task timers = Task.Run(() => TimersAsync(session.Token), session.Token);
|
||||
try
|
||||
{
|
||||
await ReadAsync(telnet, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.Cancel();
|
||||
await Ignoring(timers).ConfigureAwait(false);
|
||||
telnet = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReadAsync(TelnetStream stream, CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
string? line = await reader.ReadLineAsync(cancellation).ConfigureAwait(false);
|
||||
if (line is null)
|
||||
string? text = await stream.ReadAsync(cancellation).ConfigureAwait(false);
|
||||
if (text is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
LineArrived?.Invoke(this, line);
|
||||
Spot? spot = SpotLine.Parse(line, DateTime.UtcNow);
|
||||
if (spot is not null)
|
||||
lastHeard = DateTime.UtcNow;
|
||||
foreach (string line in lines.Add(text))
|
||||
{
|
||||
SpotArrived?.Invoke(this, spot);
|
||||
continue;
|
||||
await TakeLineAsync(line, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
if (!loggedIn && AsksForCallsign(line))
|
||||
// the login prompt has no line ending, so the tail counts too
|
||||
if (login != Login.Done)
|
||||
{
|
||||
loggedIn = true;
|
||||
await SendAsync(callsign, cancellation).ConfigureAwait(false);
|
||||
foreach (string command in commandsAfterLogin)
|
||||
{
|
||||
await SendAsync(command, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
await TryLoginAsync(lines.Pending, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nodes ask in their own words; all of them use one of these.
|
||||
private static bool AsksForCallsign(string line) =>
|
||||
line.Contains("login", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("call", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("callsign", StringComparison.OrdinalIgnoreCase);
|
||||
private async Task TakeLineAsync(string line, CancellationToken cancellation)
|
||||
{
|
||||
LineArrived?.Invoke(this, line);
|
||||
Spot? spot = SpotLine.Parse(line, DateTime.UtcNow);
|
||||
if (spot is not null)
|
||||
{
|
||||
SpotArrived?.Invoke(this, spot);
|
||||
return;
|
||||
}
|
||||
if (login != Login.Done)
|
||||
{
|
||||
await TryLoginAsync(line, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryLoginAsync(string text, CancellationToken cancellation)
|
||||
{
|
||||
switch (login)
|
||||
{
|
||||
case Login.WaitingForCallsign when ClusterPrompts.AsksForCallsign(text):
|
||||
await SendCallsignAsync(cancellation).ConfigureAwait(false);
|
||||
break;
|
||||
case Login.WaitingForPassword when password.Length > 0 && ClusterPrompts.AsksForPassword(text):
|
||||
await SendAsync(password, cancellation).ConfigureAwait(false);
|
||||
await FinishLoginAsync(cancellation).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendCallsignAsync(CancellationToken cancellation)
|
||||
{
|
||||
login = Login.WaitingForPassword;
|
||||
deadline = DateTime.UtcNow + PasswordDeadline;
|
||||
await SendAsync(callsign, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task FinishLoginAsync(CancellationToken cancellation)
|
||||
{
|
||||
login = Login.Done;
|
||||
foreach (string command in commandsAfterLogin)
|
||||
{
|
||||
await SendAsync(command, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the callsign when no prompt turned up, moves on when no password
|
||||
/// was asked for, and keeps an idle connection open.
|
||||
private async Task TimersAsync(CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellation).ConfigureAwait(false);
|
||||
DateTime now = DateTime.UtcNow;
|
||||
if (login == Login.WaitingForCallsign && now > deadline)
|
||||
{
|
||||
await SendCallsignAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
else if (login == Login.WaitingForPassword && now > deadline)
|
||||
{
|
||||
await FinishLoginAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
if (login == Login.Done && now - lastHeard > KeepAliveInterval)
|
||||
{
|
||||
lastHeard = now;
|
||||
await SendAsync("", cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task Ignoring(Task task)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void SetConnected(bool connected)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user