using System.Globalization; using System.Net.Sockets; using Nonemm.Core; using Nonemm.Spotting.Telnet; namespace Nonemm.Spotting; /// A DX cluster node over telnet. Spots go to the bandmap; the rest of the /// node's traffic goes to the telnet window as it arrived, because filters are /// the node's business and its replies are for the operator to read. public sealed class ClusterClient : IDisposable { /// How much traffic is kept for a window that opens after the connection /// did, so it does not start blank. private const int LinesRemembered = 200; /// 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); private readonly string callsign; private readonly string password; private readonly IReadOnlyList commandsAfterLogin; private readonly bool autoLogon; private readonly TimeSpan keepAliveInterval; private readonly TimeSpan retryInterval; private readonly CancellationTokenSource stopping = new(); private readonly LineAssembler lines = new(); private readonly Queue recent = new(); private readonly SolarReader solar = new(); private readonly Lock guard = new(); private TcpClient? client; 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, bool autoLogon = true, TimeSpan? keepAliveInterval = null) { Host = host; Port = port; this.callsign = callsign; this.password = password; this.commandsAfterLogin = commandsAfterLogin ?? []; this.autoLogon = autoLogon; // nodes drop a connection that has said nothing for a quarter of an hour this.keepAliveInterval = keepAliveInterval ?? TimeSpan.FromMinutes(4); this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(10); } private enum Login { WaitingForCallsign, WaitingForPassword, Done, } public string Host { get; } public int Port { get; } public bool IsConnected { get; private set; } /// Which cluster program the node runs, read out of what it says while /// logging in. Unknown until it says something that names one. public ClusterKind Kind { get; private set; } public event EventHandler? SpotArrived; public event EventHandler? LineArrived; /// The node sent solar weather, as the text to show. public event EventHandler? SolarArrived; /// What went out to the node, so the telnet window can show the login and /// the commands beside the node's answers. public event EventHandler? LineSent; public event EventHandler? ConnectionChanged; /// The traffic so far, oldest first, for a window that has just opened. public IReadOnlyList Recent { get { lock (guard) { return [.. recent]; } } } public void Start() => loop ??= Task.Run(() => RunAsync(stopping.Token)); public async Task SendAsync(string line, CancellationToken cancellation = default) { if (telnet is null) { return; } try { await telnet.WriteLineAsync(line, cancellation).ConfigureAwait(false); if (line.Length > 0) { Remember(line, sent: true); LineSent?.Invoke(this, line); } } catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException) { SetConnected(false); } } /// 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(); client?.Dispose(); } private async Task RunAsync(CancellationToken cancellation) { if (Host.Length == 0) { Notice("*** no cluster node is configured"); return; } try { while (!cancellation.IsCancellationRequested) { try { await SessionAsync(cancellation).ConfigureAwait(false); } catch (Exception e) when ( e is IOException or SocketException or ArgumentException or ObjectDisposedException) { if (cancellation.IsCancellationRequested) { return; } Notice($"*** {Host}:{Port} {e.Message}"); } SetConnected(false); await Task.Delay(retryInterval, cancellation).ConfigureAwait(false); } } catch (OperationCanceledException) { } catch (Exception e) { // anything else would end the loop with nothing on the screen Notice($"*** {Host}:{Port} stopped: {e}"); SetConnected(false); } } private async Task SessionAsync(CancellationToken cancellation) { client?.Dispose(); client = new TcpClient(); await client.ConnectAsync(Host, Port, cancellation).ConfigureAwait(false); telnet = new TelnetStream(client.GetStream()); lines.Clear(); Kind = ClusterKind.Unknown; login = autoLogon ? Login.WaitingForCallsign : Login.Done; deadline = DateTime.UtcNow + LoginDeadline; lastHeard = DateTime.UtcNow; SetConnected(true); 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? text = await stream.ReadAsync(cancellation).ConfigureAwait(false); if (text is null) { 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) { Remember(line, sent: false); LineArrived?.Invoke(this, line); if (Kind == ClusterKind.Unknown && ClusterPrompts.KindOf(line) is var kind and not ClusterKind.Unknown) { Kind = kind; ConnectionChanged?.Invoke(this, IsConnected); } if (solar.Read(line) is { } weather) { SolarArrived?.Invoke(this, weather); } 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 void Notice(string line) { Remember(line, sent: false); LineArrived?.Invoke(this, line); } private void Remember(string text, bool sent) { lock (guard) { recent.Enqueue(new ClusterLine(text, sent)); while (recent.Count > LinesRemembered) { recent.Dequeue(); } } } private static async Task Ignoring(Task task) { try { await task.ConfigureAwait(false); } catch (OperationCanceledException) { } } private void SetConnected(bool connected) { if (IsConnected != connected) { IsConnected = connected; ConnectionChanged?.Invoke(this, connected); } } }