diff --git a/README.md b/README.md index 60c5d84..e8d0d27 100644 --- a/README.md +++ b/README.md @@ -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 | | 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 | -| 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 | | 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 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 **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, which are left out rather than shown empty. -The radio, cluster, network and keyer clients are tested against fakes that -speak the documented protocols. None has yet been run against a real radio, a -live cluster node or a keyer. +The radio, network and keyer clients are tested against fakes that speak the +documented protocols. None has been run against a real radio or keyer. The +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. diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 0f4ba1f..ff24667 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -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(); diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index bc70c63..e3e0bdc 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -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 ClusterCommands { get; init; } = []; public string RigctldHost { get; init; } = "127.0.0.1"; diff --git a/src/Nonemm.App/Dialogs/ClusterDialog.axaml b/src/Nonemm.App/Dialogs/ClusterDialog.axaml index 5777754..0a725ac 100644 --- a/src/Nonemm.App/Dialogs/ClusterDialog.axaml +++ b/src/Nonemm.App/Dialogs/ClusterDialog.axaml @@ -10,6 +10,8 @@ + + diff --git a/src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs b/src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs index 2e154a2..d751f0d 100644 --- a/src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs +++ b/src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs @@ -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()) diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs index 07f412c..d1c8f69 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs @@ -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 ?? "")); diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index 6025102..67d38e9 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -41,6 +41,7 @@ + diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index 490cd92..4bc7566 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -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(); diff --git a/src/Nonemm.Spotting/ClusterClient.cs b/src/Nonemm.Spotting/ClusterClient.cs index 244c462..11f4c81 100644 --- a/src/Nonemm.Spotting/ClusterClient.cs +++ b/src/Nonemm.Spotting/ClusterClient.cs @@ -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 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? 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? 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) { diff --git a/src/Nonemm.Spotting/SpotLine.cs b/src/Nonemm.Spotting/SpotLine.cs index 05e6076..63f5435 100644 --- a/src/Nonemm.Spotting/SpotLine.cs +++ b/src/Nonemm.Spotting/SpotLine.cs @@ -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 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; - } } diff --git a/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs b/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs new file mode 100644 index 0000000..330feaf --- /dev/null +++ b/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs @@ -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 phrases) + { + foreach (string phrase in phrases) + { + if (text.Contains(phrase, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } +} diff --git a/src/Nonemm.Spotting/Telnet/LineAssembler.cs b/src/Nonemm.Spotting/Telnet/LineAssembler.cs new file mode 100644 index 0000000..6038d54 --- /dev/null +++ b/src/Nonemm.Spotting/Telnet/LineAssembler.cs @@ -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 Add(string text) + { + List 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(); +} diff --git a/src/Nonemm.Spotting/Telnet/TelnetStream.cs b/src/Nonemm.Spotting/Telnet/TelnetStream.cs new file mode 100644 index 0000000..cf087cb --- /dev/null +++ b/src/Nonemm.Spotting/Telnet/TelnetStream.cs @@ -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 theirOptions = []; + private readonly HashSet 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 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 DecodeAsync(ReadOnlyMemory block, CancellationToken cancellation) + { + List text = new(block.Length); + List 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 text, List 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 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 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; +} diff --git a/tests/Nonemm.Spotting.Tests/ClusterClientTests.cs b/tests/Nonemm.Spotting.Tests/ClusterClientTests.cs new file mode 100644 index 0000000..76e0e09 --- /dev/null +++ b/tests/Nonemm.Spotting.Tests/ClusterClientTests.cs @@ -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? commands = null) => + new("127.0.0.1", Port, "DL1ABC", commands, retryInterval: TimeSpan.FromMinutes(1)); + + private static async Task 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 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 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)); + } +} diff --git a/tests/Nonemm.Spotting.Tests/ClusterPromptsTests.cs b/tests/Nonemm.Spotting.Tests/ClusterPromptsTests.cs new file mode 100644 index 0000000..4acf809 --- /dev/null +++ b/tests/Nonemm.Spotting.Tests/ClusterPromptsTests.cs @@ -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: ")); +} diff --git a/tests/Nonemm.Spotting.Tests/FakeTelnetStream.cs b/tests/Nonemm.Spotting.Tests/FakeTelnetStream.cs new file mode 100644 index 0000000..0cb06a7 --- /dev/null +++ b/tests/Nonemm.Spotting.Tests/FakeTelnetStream.cs @@ -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 blocks; + private readonly List written = []; + + public FakeTelnetStream(params byte[][] blocks) => this.blocks = new Queue(blocks); + + public IReadOnlyList 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(); +} diff --git a/tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs b/tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs new file mode 100644 index 0000000..632ca2f --- /dev/null +++ b/tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs @@ -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); + } +} diff --git a/tests/Nonemm.Spotting.Tests/SpotLineTests.cs b/tests/Nonemm.Spotting.Tests/SpotLineTests.cs index 39e398c..a8858b3 100644 --- a/tests/Nonemm.Spotting.Tests/SpotLineTests.cs +++ b/tests/Nonemm.Spotting.Tests/SpotLineTests.cs @@ -31,4 +31,39 @@ public class SpotLineTests 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); } + + /// 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)); } diff --git a/tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs b/tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs new file mode 100644 index 0000000..4fe413b --- /dev/null +++ b/tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs @@ -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 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 written) = await Run( + [Iac, Do, TerminalType, .. Text("login: ")]); + + Assert.Equal("login: ", read); + Assert.Equal([Iac, Wont, TerminalType], written); + } + + [Fact] + public async Task AnOptionWeSupportIsAgreedTo() + { + (_, IReadOnlyList written) = await Run([Iac, Will, SuppressGoAhead]); + + Assert.Equal([Iac, Do, SuppressGoAhead], written); + } + + [Fact] + public async Task AnOptionWeDoNotSupportIsRefused() + { + (_, IReadOnlyList 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 written) = await Run( + [Iac, Will, Echo], + [Iac, Will, Echo]); + + Assert.Equal([Iac, Do, Echo], written); + } + + [Fact] + public async Task AnUnwantedOptionIsNotRefusedAgainAfterItIsOff() + { + (_, IReadOnlyList 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 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); + } +}