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

@@ -47,7 +47,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
| Windows | entry, log, check, bandmap, score summary, packet | | Windows | entry, log, check, bandmap, score summary, packet |
| Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations | | Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations |
| Radio | hamlib `rigctld`, reconnecting on its own | | Radio | hamlib `rigctld`, reconnecting on its own |
| Cluster | DX cluster over telnet, spots feeding the bandmap | | Cluster | DX cluster over telnet, spots feeding the bandmap, Alt+P to spot a station |
| Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message | | Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message |
| Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros | | Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros |
@@ -119,6 +119,25 @@ Correcting the country prefix by hand changes the score. The country file is a
best guess for calls it has no rule for, so what the contact says now wins over best guess for calls it has no rule for, so what the contact says now wins over
what the file says. what the file says.
### The DX cluster
**Config → Cluster** takes the node's address, a password for the few nodes that
ask for one, and the commands to send after login. Filters are the node's
business, so whatever goes in the command box is sent as typed and left alone.
The client speaks telnet properly: it answers the option negotiation instead of
letting the control bytes turn up in the first lines of text, and it reads the
login prompt out of a partial line, because nodes write `login: ` with no line
ending. If no prompt arrives within ten seconds the callsign goes out anyway,
which is what N1MM does. A connection that has heard nothing for four minutes
gets a blank line, so the node does not drop it as idle. A dropped connection is
retried.
Alt+P, or **Edit → Spot It**, puts the call being typed on the cluster at the
current frequency; with nothing typed it spots the last contact logged. The spot
also goes straight onto our own bandmap rather than waiting to come back round
from the node.
### Networked stations ### Networked stations
**Config → Network** names this station and lists the others. Each contact is **Config → Network** names this station and lists the others. Each contact is
@@ -167,6 +186,8 @@ Not yet: voice keying, QTC handling for WAE, call history files, digital modes
beyond logging them, and the check window's Call History and Exchange columns, beyond logging them, and the check window's Call History and Exchange columns,
which are left out rather than shown empty. which are left out rather than shown empty.
The radio, cluster, network and keyer clients are tested against fakes that The radio, network and keyer clients are tested against fakes that speak the
speak the documented protocols. None has yet been run against a real radio, a documented protocols. None has been run against a real radio or keyer. The
live cluster node or a keyer. cluster client is tested over a real socket against a node fake that sends the
telnet negotiation, the login prompt and spot lines, but not against a live
node.

View File

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

View File

@@ -16,6 +16,9 @@ public sealed record Settings
public int ClusterPort { get; init; } = 7373; 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 IReadOnlyList<string> ClusterCommands { get; init; } = [];
public string RigctldHost { get; init; } = "127.0.0.1"; 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" /> <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" /> <TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
</Grid> </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" /> <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" /> <TextBox Name="CommandsBox" AcceptsReturn="True" Height="90" />
<CheckBox Name="EnabledBox" Content="Connect" Margin="0,6,0,0" /> <CheckBox Name="EnabledBox" Content="Connect" Margin="0,6,0,0" />

View File

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

View File

@@ -6,6 +6,7 @@ using Nonemm.App.Dialogs;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Formats.Adif; using Nonemm.Formats.Adif;
using Nonemm.Formats.Cabrillo; using Nonemm.Formats.Cabrillo;
using Nonemm.Spotting;
using Nonemm.Storage; using Nonemm.Storage;
namespace Nonemm.App.Windows; namespace Nonemm.App.Windows;
@@ -186,6 +187,43 @@ public sealed partial class EntryWindow
await new EditContactDialog(Logging, Logging.Log.Qsos[^1].Id).ShowDialog(this); 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 OnShowLog(object? sender, RoutedEventArgs e) => Show(() => new LogWindow(session));
private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session, () => Logging?.Entry.Call ?? "")); private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session, () => Logging?.Entry.Call ?? ""));

View File

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

View File

@@ -183,6 +183,10 @@ public sealed partial class EntryWindow : Window
e.Handled = true; e.Handled = true;
OnEditLastContact(this, new RoutedEventArgs()); OnEditLastContact(this, new RoutedEventArgs());
break; 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): case Key.OemQuestion when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true; e.Handled = true;
ToggleRun(); ToggleRun();

View File

@@ -1,5 +1,7 @@
using System.Globalization;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using Nonemm.Core;
using Nonemm.Spotting.Telnet;
namespace Nonemm.Spotting; namespace Nonemm.Spotting;
@@ -8,30 +10,54 @@ namespace Nonemm.Spotting;
/// the node's business and its replies are for the operator to read. /// the node's business and its replies are for the operator to read.
public sealed class ClusterClient : IDisposable 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 string host;
private readonly int port; private readonly int port;
private readonly string callsign; private readonly string callsign;
private readonly string password;
private readonly IReadOnlyList<string> commandsAfterLogin; private readonly IReadOnlyList<string> commandsAfterLogin;
private readonly TimeSpan retryInterval; private readonly TimeSpan retryInterval;
private readonly CancellationTokenSource stopping = new(); private readonly CancellationTokenSource stopping = new();
private readonly LineAssembler lines = new();
private TcpClient? client; private TcpClient? client;
private StreamWriter? writer; private TelnetStream? telnet;
private Task? loop; private Task? loop;
private Login login = Login.WaitingForCallsign;
private DateTime deadline;
private DateTime lastHeard;
public ClusterClient( public ClusterClient(
string host, string host,
int port, int port,
string callsign, string callsign,
IReadOnlyList<string>? commandsAfterLogin = null, IReadOnlyList<string>? commandsAfterLogin = null,
string password = "",
TimeSpan? retryInterval = null) TimeSpan? retryInterval = null)
{ {
this.host = host; this.host = host;
this.port = port; this.port = port;
this.callsign = callsign; this.callsign = callsign;
this.password = password;
this.commandsAfterLogin = commandsAfterLogin ?? []; this.commandsAfterLogin = commandsAfterLogin ?? [];
this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(10); this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(10);
} }
private enum Login
{
WaitingForCallsign,
WaitingForPassword,
Done,
}
public bool IsConnected { get; private set; } public bool IsConnected { get; private set; }
public event EventHandler<Spot>? SpotArrived; public event EventHandler<Spot>? SpotArrived;
@@ -44,13 +70,13 @@ public sealed class ClusterClient : IDisposable
public async Task SendAsync(string line, CancellationToken cancellation = default) public async Task SendAsync(string line, CancellationToken cancellation = default)
{ {
if (writer is null) if (telnet is null)
{ {
return; return;
} }
try 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) 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() public void Dispose()
{ {
stopping.Cancel(); stopping.Cancel();
@@ -91,43 +129,129 @@ public sealed class ClusterClient : IDisposable
client?.Dispose(); client?.Dispose();
client = new TcpClient(); client = new TcpClient();
await client.ConnectAsync(host, port, cancellation).ConfigureAwait(false); await client.ConnectAsync(host, port, cancellation).ConfigureAwait(false);
NetworkStream stream = client.GetStream(); telnet = new TelnetStream(client.GetStream());
using StreamReader reader = new(stream, Encoding.Latin1); lines.Clear();
writer = new StreamWriter(stream, Encoding.Latin1) { AutoFlush = true }; login = Login.WaitingForCallsign;
deadline = DateTime.UtcNow + LoginDeadline;
lastHeard = DateTime.UtcNow;
SetConnected(true); 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) while (!cancellation.IsCancellationRequested)
{ {
string? line = await reader.ReadLineAsync(cancellation).ConfigureAwait(false); string? text = await stream.ReadAsync(cancellation).ConfigureAwait(false);
if (line is null) if (text is null)
{ {
return; return;
} }
lastHeard = DateTime.UtcNow;
foreach (string line in lines.Add(text))
{
await TakeLineAsync(line, cancellation).ConfigureAwait(false);
}
// the login prompt has no line ending, so the tail counts too
if (login != Login.Done)
{
await TryLoginAsync(lines.Pending, cancellation).ConfigureAwait(false);
}
}
}
private async Task TakeLineAsync(string line, CancellationToken cancellation)
{
LineArrived?.Invoke(this, line); LineArrived?.Invoke(this, line);
Spot? spot = SpotLine.Parse(line, DateTime.UtcNow); Spot? spot = SpotLine.Parse(line, DateTime.UtcNow);
if (spot is not null) if (spot is not null)
{ {
SpotArrived?.Invoke(this, spot); SpotArrived?.Invoke(this, spot);
continue; return;
} }
if (!loggedIn && AsksForCallsign(line)) if (login != Login.Done)
{ {
loggedIn = true; 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); await SendAsync(callsign, cancellation).ConfigureAwait(false);
}
private async Task FinishLoginAsync(CancellationToken cancellation)
{
login = Login.Done;
foreach (string command in commandsAfterLogin) foreach (string command in commandsAfterLogin)
{ {
await SendAsync(command, cancellation).ConfigureAwait(false); 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);
}
} }
} }
/// Nodes ask in their own words; all of them use one of these. private static async Task Ignoring(Task task)
private static bool AsksForCallsign(string line) => {
line.Contains("login", StringComparison.OrdinalIgnoreCase) || try
line.Contains("call", StringComparison.OrdinalIgnoreCase) || {
line.Contains("callsign", StringComparison.OrdinalIgnoreCase); await task.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
}
private void SetConnected(bool connected) private void SetConnected(bool connected)
{ {

View File

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

View File

@@ -0,0 +1,126 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace Nonemm.Spotting.Tests;
/// The client driven through a real socket, so the telnet negotiation, the
/// login and the spot parsing are exercised together.
public class ClusterClientTests : IDisposable
{
private const byte Iac = 255;
private const byte Wont = 252;
private const byte Do = 253;
private const byte TerminalType = 24;
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5);
private readonly TcpListener listener;
public ClusterClientTests()
{
listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
}
private int Port => ((IPEndPoint)listener.LocalEndpoint).Port;
public void Dispose() => listener.Stop();
private ClusterClient Connect(IReadOnlyList<string>? commands = null) =>
new("127.0.0.1", Port, "DL1ABC", commands, retryInterval: TimeSpan.FromMinutes(1));
private static async Task<byte[]> ReadAsync(NetworkStream stream, int bytes)
{
byte[] buffer = new byte[bytes];
int read = 0;
while (read < bytes)
{
int got = await stream.ReadAsync(buffer.AsMemory(read));
if (got == 0)
{
break;
}
read += got;
}
return buffer[..read];
}
private static async Task<string> ReadTextAsync(NetworkStream stream, int bytes) =>
Encoding.Latin1.GetString(await ReadAsync(stream, bytes));
[Fact]
public async Task TheCallsignGoesOutWhenTheNodeAsksForIt()
{
using ClusterClient cluster = Connect();
cluster.Start();
using TcpClient node = await listener.AcceptTcpClientAsync().WaitAsync(Patience);
NetworkStream stream = node.GetStream();
await stream.WriteAsync(new byte[] { Iac, Do, TerminalType });
await stream.WriteAsync(Encoding.Latin1.GetBytes("login: "));
byte[] sent = await ReadAsync(stream, 3 + 8).WaitAsync(Patience);
Assert.Equal(new byte[] { Iac, Wont, TerminalType }, sent[..3]);
Assert.Equal("DL1ABC\r\n", Encoding.Latin1.GetString(sent[3..]));
}
[Fact]
public async Task TheCommandsGoOutAfterTheLogin()
{
using ClusterClient cluster = Connect(["set/skimmer"]);
cluster.Start();
using TcpClient node = await listener.AcceptTcpClientAsync().WaitAsync(Patience);
NetworkStream stream = node.GetStream();
await stream.WriteAsync(Encoding.Latin1.GetBytes("Please enter your call: "));
Assert.Equal(
"DL1ABC\r\nset/skimmer\r\n",
await ReadTextAsync(stream, 8 + 13).WaitAsync(Patience));
}
[Fact]
public async Task ASpotFromTheNodeReachesTheBandmap()
{
using ClusterClient cluster = Connect();
TaskCompletionSource<Spot> arrived = new();
cluster.SpotArrived += (_, spot) => arrived.TrySetResult(spot);
cluster.Start();
using TcpClient node = await listener.AcceptTcpClientAsync().WaitAsync(Patience);
await node.GetStream().WriteAsync(Encoding.Latin1.GetBytes(
"DX de W3LPL: 14025.0 JA1XYZ CQ 1234Z\r\n"));
Spot spot = await arrived.Task.WaitAsync(Patience);
Assert.Equal("JA1XYZ", spot.Call.Text);
Assert.Equal(14_025_000, spot.Frequency.Hertz);
}
[Fact]
public async Task SpottingAStationSendsTheNodesDxCommand()
{
using ClusterClient cluster = Connect();
TaskCompletionSource connected = new();
cluster.ConnectionChanged += (_, up) =>
{
if (up)
{
connected.TrySetResult();
}
};
cluster.Start();
using TcpClient node = await listener.AcceptTcpClientAsync().WaitAsync(Patience);
await connected.Task.WaitAsync(Patience);
await cluster.SendSpotAsync(
Core.Frequency.FromKilohertz(14_025),
Core.Callsign.Parse("JA1XYZ"),
"CQ");
Assert.Equal(
"dx 14025.0 JA1XYZ CQ\r\n",
await ReadTextAsync(node.GetStream(), 22).WaitAsync(Patience));
}
}

View File

@@ -0,0 +1,24 @@
using Nonemm.Spotting.Telnet;
namespace Nonemm.Spotting.Tests;
public class ClusterPromptsTests
{
[Theory]
[InlineData("login: ")]
[InlineData("Please enter your call: ")]
[InlineData("Hello, please log in")]
[InlineData("Enter Callsign:")]
public void ANodeAskingForTheCallsignIsRecognised(string text) =>
Assert.True(ClusterPrompts.AsksForCallsign(text));
[Theory]
[InlineData("DX de W3LPL: 14025.0 JA1XYZ")]
[InlineData("")]
public void OrdinaryTrafficIsNotALoginPrompt(string text) =>
Assert.False(ClusterPrompts.AsksForCallsign(text));
[Fact]
public void APasswordPromptIsRecognised() =>
Assert.True(ClusterPrompts.AsksForPassword("password: "));
}

View File

@@ -0,0 +1,50 @@
namespace Nonemm.Spotting.Tests;
/// A stream that hands out bytes a test prepared and keeps whatever is written
/// back, so the telnet negotiation can be checked without a socket. Each read
/// returns one prepared block, the way a socket hands over one packet.
public sealed class FakeTelnetStream : Stream
{
private readonly Queue<byte[]> blocks;
private readonly List<byte> written = [];
public FakeTelnetStream(params byte[][] blocks) => this.blocks = new Queue<byte[]>(blocks);
public IReadOnlyList<byte> Written => written;
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count)
{
if (blocks.Count == 0)
{
return 0;
}
byte[] block = blocks.Dequeue();
block.CopyTo(buffer, offset);
return block.Length;
}
public override void Write(byte[] buffer, int offset, int count) =>
written.AddRange(buffer.Skip(offset).Take(count));
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
}

View File

@@ -0,0 +1,34 @@
using Nonemm.Spotting.Telnet;
namespace Nonemm.Spotting.Tests;
public class LineAssemblerTests
{
[Fact]
public void CompleteLinesComeOutWithoutTheirEndings()
{
LineAssembler assembler = new();
Assert.Equal(["one", "two"], assembler.Add("one\r\ntwo\r\n"));
Assert.Equal("", assembler.Pending);
}
[Fact]
public void ALineSplitOverTwoBlocksIsPutBackTogether()
{
LineAssembler assembler = new();
Assert.Empty(assembler.Add("DX de W3"));
Assert.Equal(["DX de W3LPL"], assembler.Add("LPL\r\n"));
}
/// The login prompt has no line ending, so it only ever shows up here.
[Fact]
public void TextWithNoLineEndingWaitsInPending()
{
LineAssembler assembler = new();
Assert.Empty(assembler.Add("login: "));
Assert.Equal("login: ", assembler.Pending);
}
}

View File

@@ -31,4 +31,39 @@ public class SpotLineTests
Spot? spot = SpotLine.Parse("DX de W3LPL: 14025.0 DL1ABC CQ 2350Z", Now); Spot? spot = SpotLine.Parse("DX de W3LPL: 14025.0 DL1ABC CQ 2350Z", Now);
Assert.Equal(new DateTime(2026, 5, 29, 23, 50, 0, DateTimeKind.Utc), spot?.AtUtc); Assert.Equal(new DateTime(2026, 5, 29, 23, 50, 0, DateTimeKind.Utc), spot?.AtUtc);
} }
/// DXSpider puts the spotter's grid after the time.
[Fact]
public void TheTimeIsFoundEvenWithSomethingAfterIt()
{
Spot? spot = SpotLine.Parse(
"DX de OK1ABC-#: 7005.0 DL1ABC CQ 1234Z JO70",
Now);
Assert.Equal(new DateTime(2026, 5, 30, 12, 34, 0, DateTimeKind.Utc), spot?.AtUtc);
Assert.Equal("CQ", spot?.Comment);
}
[Fact]
public void TheSpotterKeepsTheNodeSuffix() =>
Assert.Equal("OK1ABC-#", SpotLine.Parse("DX de OK1ABC-#: 7005.0 DL1ABC CQ 1234Z", Now)?.Spotter);
/// Not every node writes the colon after the spotter.
[Fact]
public void ASpotWithNoColonAfterTheSpotterStillReads()
{
Spot? spot = SpotLine.Parse("DX de W3LPL 14025.0 DL1ABC CQ 1234Z", Now);
Assert.Equal("W3LPL", spot?.Spotter);
Assert.Equal("DL1ABC", spot?.Call.Text);
Assert.Equal(14_025_000, spot?.Frequency.Hertz);
}
[Fact]
public void ASpotWithNoTimeIsTimedOnArrival() =>
Assert.Equal(Now, SpotLine.Parse("DX de W3LPL: 14025.0 DL1ABC CQ", Now)?.AtUtc);
[Fact]
public void ALineWithNoFrequencyIsNotASpot() =>
Assert.Null(SpotLine.Parse("DX de W3LPL: hello there", Now));
} }

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);
}
}