Add the session, radio control, bandmap and cluster
Nonemm.Session holds what the operator is typing, what the log says about it and what happens on Enter, with no UI toolkit behind it. Nonemm.Rig talks to hamlib's rigctld and reconnects on its own. Nonemm.Spotting reads DX cluster lines into a bandmap that drops spots after an hour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// A DX cluster node over telnet. Spots go to the bandmap; the rest of the
|
||||
/// node's traffic goes to the packet 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
|
||||
{
|
||||
private readonly string host;
|
||||
private readonly int port;
|
||||
private readonly string callsign;
|
||||
private readonly IReadOnlyList<string> commandsAfterLogin;
|
||||
private readonly TimeSpan retryInterval;
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
private TcpClient? client;
|
||||
private StreamWriter? writer;
|
||||
private Task? loop;
|
||||
|
||||
public ClusterClient(
|
||||
string host,
|
||||
int port,
|
||||
string callsign,
|
||||
IReadOnlyList<string>? commandsAfterLogin = null,
|
||||
TimeSpan? retryInterval = null)
|
||||
{
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.callsign = callsign;
|
||||
this.commandsAfterLogin = commandsAfterLogin ?? [];
|
||||
this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public event EventHandler<Spot>? SpotArrived;
|
||||
|
||||
public event EventHandler<string>? LineArrived;
|
||||
|
||||
public event EventHandler<bool>? ConnectionChanged;
|
||||
|
||||
public void Start() => loop ??= Task.Run(() => RunAsync(stopping.Token));
|
||||
|
||||
public async Task SendAsync(string line, CancellationToken cancellation = default)
|
||||
{
|
||||
if (writer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
await writer.WriteAsync(line + "\r\n").ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
SetConnected(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
stopping.Cancel();
|
||||
client?.Dispose();
|
||||
stopping.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SessionAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or SocketException)
|
||||
{
|
||||
LineArrived?.Invoke(this, $"*** {host}:{port} {e.Message}");
|
||||
}
|
||||
SetConnected(false);
|
||||
await Task.Delay(retryInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SessionAsync(CancellationToken cancellation)
|
||||
{
|
||||
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 };
|
||||
SetConnected(true);
|
||||
|
||||
bool loggedIn = false;
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
string? line = await reader.ReadLineAsync(cancellation).ConfigureAwait(false);
|
||||
if (line is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
LineArrived?.Invoke(this, line);
|
||||
Spot? spot = SpotLine.Parse(line, DateTime.UtcNow);
|
||||
if (spot is not null)
|
||||
{
|
||||
SpotArrived?.Invoke(this, spot);
|
||||
continue;
|
||||
}
|
||||
if (!loggedIn && AsksForCallsign(line))
|
||||
{
|
||||
loggedIn = true;
|
||||
await SendAsync(callsign, cancellation).ConfigureAwait(false);
|
||||
foreach (string command in commandsAfterLogin)
|
||||
{
|
||||
await SendAsync(command, 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 void SetConnected(bool connected)
|
||||
{
|
||||
if (IsConnected != connected)
|
||||
{
|
||||
IsConnected = connected;
|
||||
ConnectionChanged?.Invoke(this, connected);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user