Files
Nonemm/src/Nonemm.Spotting/ClusterClient.cs
T
erikbrockoandClaude Sonnet 5.5 e55159ba49 Show the cluster's solar weather in a bandmap status bar
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>
2026-10-24 12:12:35 +00:00

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