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

@@ -237,7 +237,8 @@ public sealed class AppSession : IDisposable
Settings.ClusterHost,
Settings.ClusterPort,
Settings.Station.Callsign,
Settings.ClusterCommands);
Settings.ClusterCommands,
Settings.ClusterPassword);
cluster.SpotArrived += (_, spot) => Bandmap.Add(spot);
cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
cluster.Start();

View File

@@ -16,6 +16,9 @@ public sealed record Settings
public int ClusterPort { get; init; } = 7373;
/// Only the few nodes that ask for one; most take the callsign alone.
public string ClusterPassword { get; init; } = "";
public IReadOnlyList<string> ClusterCommands { get; init; } = [];
public string RigctldHost { get; init; } = "127.0.0.1";

View File

@@ -10,6 +10,8 @@
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
<TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
</Grid>
<TextBlock Text="Password, if the node asks for one" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="PasswordBox" PasswordChar="•" />
<TextBlock Text="Commands sent after login, one per line" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="CommandsBox" AcceptsReturn="True" Height="90" />
<CheckBox Name="EnabledBox" Content="Connect" Margin="0,6,0,0" />

View File

@@ -16,6 +16,7 @@ public sealed partial class ClusterDialog : Window
InitializeComponent();
HostBox.Text = settings.ClusterHost;
PortBox.Text = settings.ClusterPort.ToString();
PasswordBox.Text = settings.ClusterPassword;
CommandsBox.Text = string.Join("\n", settings.ClusterCommands);
EnabledBox.IsChecked = settings.ClusterEnabled;
}
@@ -25,6 +26,7 @@ public sealed partial class ClusterDialog : Window
{
ClusterHost = (HostBox.Text ?? "").Trim(),
ClusterPort = int.TryParse(PortBox.Text, out int port) ? port : 7373,
ClusterPassword = PasswordBox.Text ?? "",
ClusterCommands = (CommandsBox.Text ?? "")
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim())

View File

@@ -6,6 +6,7 @@ using Nonemm.App.Dialogs;
using Nonemm.Core;
using Nonemm.Formats.Adif;
using Nonemm.Formats.Cabrillo;
using Nonemm.Spotting;
using Nonemm.Storage;
namespace Nonemm.App.Windows;
@@ -186,6 +187,43 @@ public sealed partial class EntryWindow
await new EditContactDialog(Logging, Logging.Log.Qsos[^1].Id).ShowDialog(this);
}
/// Puts the call being typed on the cluster, or the last one logged when
/// nothing is typed. That is what N1MM's Spot It button does.
private async void OnSpotIt(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
Status("no contest is open");
return;
}
if (session.Cluster is not { IsConnected: true } cluster)
{
Status("not connected to a cluster node");
return;
}
string typed = Logging.Entry.Call.Trim();
Callsign call;
Frequency where;
if (typed.Length > 0)
{
call = Callsign.Parse(typed);
where = Logging.Frequency;
}
else if (Logging.Log.Qsos.Count > 0)
{
call = Logging.Log.Qsos[^1].Call;
where = Logging.Log.Qsos[^1].Frequency;
}
else
{
Status("nothing to spot");
return;
}
await cluster.SendSpotAsync(where, call);
session.Bandmap.Add(new Spot(call, where, DateTime.UtcNow, SpotSource.Operator));
Status($"{call.Text} spotted on {where.Kilohertz:0.0}");
}
private void OnShowLog(object? sender, RoutedEventArgs e) => Show(() => new LogWindow(session));
private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session, () => Logging?.Entry.Call ?? ""));

View File

@@ -41,6 +41,7 @@
</MenuItem>
<MenuItem Header="_Edit">
<MenuItem Header="Edit _Last Contact…" Click="OnEditLastContact" InputGesture="Ctrl+Y" />
<MenuItem Header="S_pot It" Click="OnSpotIt" InputGesture="Alt+P" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="_Log" Click="OnShowLog" />

View File

@@ -183,6 +183,10 @@ public sealed partial class EntryWindow : Window
e.Handled = true;
OnEditLastContact(this, new RoutedEventArgs());
break;
case Key.P when e.KeyModifiers.HasFlag(KeyModifiers.Alt):
e.Handled = true;
OnSpotIt(this, new RoutedEventArgs());
break;
case Key.OemQuestion when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
ToggleRun();

View File

@@ -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)
{

View File

@@ -19,37 +19,55 @@ public static class SpotLine
}
string body = line[(start + Marker.Length)..];
int colon = body.IndexOf(':');
if (colon < 0)
{
return null;
}
string spotter = body[..colon].Trim();
string[] parts = body[(colon + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
string[] parts = (colon < 0 ? body : body[(colon + 1)..])
.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
return null;
}
if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double kilohertz))
// without a colon the spotter is the first word and the frequency follows
string spotter = colon >= 0 ? body[..colon].Trim() : parts[0];
string[] fields = colon >= 0 ? parts : parts[1..];
if (fields.Length < 2
|| !double.TryParse(fields[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double kilohertz))
{
return null;
}
string comment = string.Join(' ', parts.Skip(2)).Trim();
string[] tail = fields[2..];
int stamp = LastTimeStamp(tail);
return new Spot(
Callsign.Parse(parts[1]),
Callsign.Parse(fields[1]),
Frequency.FromKilohertz(kilohertz),
ParseTime(comment, nowUtc),
stamp < 0 ? nowUtc : TimeOf(tail[stamp], nowUtc),
SpotSource.Cluster,
spotter,
TrimTime(comment));
string.Join(' ', stamp < 0 ? tail : tail[..stamp]).Trim());
}
/// The node puts the spot's time at the end as `1234Z`.
private static DateTime ParseTime(string comment, DateTime nowUtc)
/// The node writes the spot's time as `1234Z`. It is usually the last word,
/// but DXSpider puts the spotter's grid or country after it.
private static int LastTimeStamp(IReadOnlyList<string> words)
{
string? stamp = TimeStamp(comment);
if (stamp is null ||
!int.TryParse(stamp[..2], out int hour) ||
!int.TryParse(stamp[2..4], out int minute))
for (int at = words.Count - 1; at >= 0; at--)
{
if (IsTimeStamp(words[at]))
{
return at;
}
}
return -1;
}
private static bool IsTimeStamp(string word) =>
word.Length == 5
&& char.ToUpperInvariant(word[4]) == 'Z'
&& word[..4].All(char.IsAsciiDigit);
private static DateTime TimeOf(string stamp, DateTime nowUtc)
{
int hour = int.Parse(stamp[..2], CultureInfo.InvariantCulture);
int minute = int.Parse(stamp[2..4], CultureInfo.InvariantCulture);
if (hour > 23 || minute > 59)
{
return nowUtc;
}
@@ -57,21 +75,4 @@ public static class SpotLine
// a spot timed later than now came in just before midnight
return at > nowUtc.AddMinutes(5) ? at.AddDays(-1) : at;
}
private static string TrimTime(string comment)
{
string? stamp = TimeStamp(comment);
return stamp is null ? comment : comment[..^stamp.Length].TrimEnd();
}
private static string? TimeStamp(string comment)
{
string trimmed = comment.TrimEnd();
if (trimmed.Length < 5 || char.ToUpperInvariant(trimmed[^1]) != 'Z')
{
return null;
}
string candidate = trimmed[^5..];
return candidate[..4].All(char.IsAsciiDigit) ? candidate : null;
}
}

View File

@@ -0,0 +1,30 @@
namespace Nonemm.Spotting.Telnet;
/// Reads what a cluster node is asking for at login. Every node asks in its own
/// words; these are the ones DXSpider, AR-Cluster, CC Cluster and DXNet use.
public static class ClusterPrompts
{
private static readonly string[] Callsign =
[
"login", "log in", "logon", "enter call", "enter your call",
"your call", "callsign", "call:",
];
private static readonly string[] Password = ["password"];
public static bool AsksForCallsign(string text) => Holds(text, Callsign);
public static bool AsksForPassword(string text) => Holds(text, Password);
private static bool Holds(string text, IReadOnlyList<string> phrases)
{
foreach (string phrase in phrases)
{
if (text.Contains(phrase, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,34 @@
using System.Text;
namespace Nonemm.Spotting.Telnet;
/// Splits the text coming off a telnet stream into lines and keeps the tail
/// that has no line ending yet. Cluster nodes write their login prompt without
/// one, so the tail has to be readable before the line is finished.
public sealed class LineAssembler
{
private readonly StringBuilder pending = new();
/// What has arrived since the last line ending.
public string Pending => pending.ToString();
public IReadOnlyList<string> Add(string text)
{
List<string> lines = [];
foreach (char character in text)
{
if (character == '\n')
{
lines.Add(pending.ToString().TrimEnd('\r'));
pending.Clear();
}
else
{
pending.Append(character);
}
}
return lines;
}
public void Clear() => pending.Clear();
}

View File

@@ -0,0 +1,187 @@
using System.Text;
namespace Nonemm.Spotting.Telnet;
/// A telnet connection, RFC 854. It answers the option negotiation, drops the
/// control bytes and hands back the text the other end sent. Cluster nodes open
/// with a handful of WILL and DO commands; a client that does not answer them
/// gets the raw bytes mixed into the first lines it reads.
public sealed class TelnetStream
{
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;
/// What we let the node turn on, and what we agree to do ourselves. Every
/// other option is refused.
private static readonly byte[] TheyMay = [Echo, SuppressGoAhead];
private static readonly byte[] WeMay = [SuppressGoAhead];
private readonly Stream stream;
private readonly byte[] buffer = new byte[4096];
private readonly HashSet<byte> theirOptions = [];
private readonly HashSet<byte> myOptions = [];
private readonly SemaphoreSlim writing = new(1, 1);
private State state = State.Text;
private byte command;
public TelnetStream(Stream stream) => this.stream = stream;
private enum State
{
Text,
Command,
Option,
Subnegotiation,
SubnegotiationIac,
}
/// The text of the next block, or null when the other end closed. An empty
/// string means the block held nothing but telnet commands.
public async Task<string?> ReadAsync(CancellationToken cancellation = default)
{
int read = await stream.ReadAsync(buffer, cancellation).ConfigureAwait(false);
if (read == 0)
{
return null;
}
return await DecodeAsync(buffer.AsMemory(0, read), cancellation).ConfigureAwait(false);
}
public async Task WriteLineAsync(string text, CancellationToken cancellation = default)
{
await WriteAsync(Encoding.Latin1.GetBytes(Escaped(text) + "\r\n"), cancellation)
.ConfigureAwait(false);
}
private async Task<string> DecodeAsync(ReadOnlyMemory<byte> block, CancellationToken cancellation)
{
List<byte> text = new(block.Length);
List<byte> answer = [];
foreach (byte value in block.Span)
{
Step(value, text, answer);
}
if (answer.Count > 0)
{
await WriteAsync([.. answer], cancellation).ConfigureAwait(false);
}
return Encoding.Latin1.GetString([.. text]);
}
private void Step(byte value, List<byte> text, List<byte> answer)
{
switch (state)
{
case State.Text when value == Iac:
state = State.Command;
break;
// CR NUL is how telnet writes a bare carriage return
case State.Text when value != 0:
text.Add(value);
break;
case State.Text:
break;
case State.Command:
Command(value, text);
break;
case State.Option:
Negotiate(command, value, answer);
state = State.Text;
break;
case State.Subnegotiation:
state = value == Iac ? State.SubnegotiationIac : State.Subnegotiation;
break;
case State.SubnegotiationIac:
state = value == SubnegotiationEnd ? State.Text : State.Subnegotiation;
break;
}
}
private void Command(byte value, List<byte> text)
{
switch (value)
{
case Iac:
text.Add(Iac);
state = State.Text;
break;
case SubnegotiationBegin:
state = State.Subnegotiation;
break;
case Will or Wont or Do or Dont:
command = value;
state = State.Option;
break;
default:
// NOP, GO AHEAD, ARE YOU THERE and the rest need no answer
state = State.Text;
break;
}
}
/// The answers are tracked per option so a repeated command does not start
/// the two ends answering each other for ever.
private void Negotiate(byte what, byte option, List<byte> answer)
{
switch (what)
{
case Will when TheyMay.Contains(option):
if (theirOptions.Add(option))
{
answer.AddRange([Iac, Do, option]);
}
break;
case Will:
answer.AddRange([Iac, Dont, option]);
break;
case Wont:
if (theirOptions.Remove(option))
{
answer.AddRange([Iac, Dont, option]);
}
break;
case Do when WeMay.Contains(option):
if (myOptions.Add(option))
{
answer.AddRange([Iac, Will, option]);
}
break;
case Do:
answer.AddRange([Iac, Wont, option]);
break;
case Dont:
if (myOptions.Remove(option))
{
answer.AddRange([Iac, Wont, option]);
}
break;
}
}
private async Task WriteAsync(byte[] bytes, CancellationToken cancellation)
{
await writing.WaitAsync(cancellation).ConfigureAwait(false);
try
{
await stream.WriteAsync(bytes, cancellation).ConfigureAwait(false);
await stream.FlushAsync(cancellation).ConfigureAwait(false);
}
finally
{
writing.Release();
}
}
/// A 255 in the text has to be doubled or the other end reads it as IAC.
private static string Escaped(string text) =>
text.Contains('ÿ') ? text.Replace("ÿ", "ÿÿ") : text;
}