The reader follows N1MM's rules for WWV and WCY lines and the sh/wwv table. The bar is hidden until the cluster has sent some. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
352 lines
11 KiB
C#
352 lines
11 KiB
C#
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<string> 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<ClusterLine> 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<string>? 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<Spot>? SpotArrived;
|
|
|
|
public event EventHandler<string>? LineArrived;
|
|
|
|
/// The node sent solar weather, as the text to show.
|
|
public event EventHandler<string>? 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<string>? LineSent;
|
|
|
|
public event EventHandler<bool>? ConnectionChanged;
|
|
|
|
/// The traffic so far, oldest first, for a window that has just opened.
|
|
public IReadOnlyList<ClusterLine> 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);
|
|
}
|
|
}
|
|
}
|