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,198 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Rig;
|
||||
|
||||
/// Talks to hamlib's `rigctld`, which is started separately for whichever radio
|
||||
/// is on the desk. The connection is retried on its own, so a radio switched on
|
||||
/// late or a `rigctld` restarted mid-contest comes back without the operator
|
||||
/// touching anything.
|
||||
public sealed class RigctldRadio : Radio
|
||||
{
|
||||
private readonly string host;
|
||||
private readonly int port;
|
||||
private readonly TimeSpan pollInterval;
|
||||
private readonly TimeSpan retryInterval;
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
private readonly SemaphoreSlim writing = new(1, 1);
|
||||
private TcpClient? client;
|
||||
private StreamReader? reader;
|
||||
private StreamWriter? writer;
|
||||
private Task? loop;
|
||||
|
||||
public RigctldRadio(
|
||||
string host = "127.0.0.1",
|
||||
int port = 4532,
|
||||
TimeSpan? pollInterval = null,
|
||||
TimeSpan? retryInterval = null)
|
||||
{
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.pollInterval = pollInterval ?? TimeSpan.FromMilliseconds(200);
|
||||
this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(3);
|
||||
}
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public RadioState? State { get; private set; }
|
||||
|
||||
public event EventHandler<RadioState>? Moved;
|
||||
|
||||
public event EventHandler<bool>? ConnectionChanged;
|
||||
|
||||
public void Start() => loop ??= Task.Run(() => RunAsync(stopping.Token));
|
||||
|
||||
public async Task TuneAsync(Frequency frequency, CancellationToken cancellation = default) =>
|
||||
await SendAsync($"F {frequency.Hertz}", cancellation).ConfigureAwait(false);
|
||||
|
||||
public async Task SetModeAsync(Mode mode, CancellationToken cancellation = default) =>
|
||||
await SendAsync($"M {RigctldMode(mode)} 0", cancellation).ConfigureAwait(false);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
stopping.Cancel();
|
||||
client?.Dispose();
|
||||
stopping.Dispose();
|
||||
writing.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ConnectAsync(cancellation).ConfigureAwait(false);
|
||||
await PollAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or SocketException)
|
||||
{
|
||||
SetConnected(false);
|
||||
await Task.Delay(retryInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConnectAsync(CancellationToken cancellation)
|
||||
{
|
||||
client?.Dispose();
|
||||
client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, cancellation).ConfigureAwait(false);
|
||||
NetworkStream stream = client.GetStream();
|
||||
reader = new StreamReader(stream);
|
||||
writer = new StreamWriter(stream) { AutoFlush = true };
|
||||
SetConnected(true);
|
||||
}
|
||||
|
||||
private async Task PollAsync(CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
Frequency? frequency = ReadFrequency(await AskAsync("f", cancellation).ConfigureAwait(false));
|
||||
Mode? mode = ReadMode(await AskAsync("m", cancellation).ConfigureAwait(false));
|
||||
if (frequency is not null)
|
||||
{
|
||||
RadioState state = new(frequency.Value, mode ?? State?.Mode ?? Modes.Cw);
|
||||
if (state != State)
|
||||
{
|
||||
State = state;
|
||||
Moved?.Invoke(this, state);
|
||||
}
|
||||
}
|
||||
await Task.Delay(pollInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> AskAsync(string command, CancellationToken cancellation)
|
||||
{
|
||||
await writing.WaitAsync(cancellation).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (writer is null || reader is null)
|
||||
{
|
||||
throw new IOException("the connection to rigctld is not open");
|
||||
}
|
||||
await writer.WriteAsync(command + "\n").ConfigureAwait(false);
|
||||
List<string> lines = [];
|
||||
// rigctld answers one value per line and ends with RPRT on an error
|
||||
string? line = await reader.ReadLineAsync(cancellation).ConfigureAwait(false)
|
||||
?? throw new IOException($"rigctld closed the connection during '{command}'");
|
||||
lines.Add(line);
|
||||
if (command == "m")
|
||||
{
|
||||
string? passband = await reader.ReadLineAsync(cancellation).ConfigureAwait(false);
|
||||
if (passband is not null)
|
||||
{
|
||||
lines.Add(passband);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
finally
|
||||
{
|
||||
writing.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendAsync(string command, CancellationToken cancellation)
|
||||
{
|
||||
if (!IsConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await writing.WaitAsync(cancellation).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (writer is null || reader is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await writer.WriteAsync(command + "\n").ConfigureAwait(false);
|
||||
await reader.ReadLineAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or SocketException)
|
||||
{
|
||||
SetConnected(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
writing.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void SetConnected(bool connected)
|
||||
{
|
||||
if (IsConnected != connected)
|
||||
{
|
||||
IsConnected = connected;
|
||||
ConnectionChanged?.Invoke(this, connected);
|
||||
}
|
||||
}
|
||||
|
||||
private static Frequency? ReadFrequency(IReadOnlyList<string> answer) =>
|
||||
answer.Count > 0 &&
|
||||
long.TryParse(answer[0].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long hertz) &&
|
||||
hertz > 0
|
||||
? Frequency.FromHertz(hertz)
|
||||
: null;
|
||||
|
||||
private static Mode? ReadMode(IReadOnlyList<string> answer) =>
|
||||
answer.Count > 0 ? Modes.Parse(answer[0].Trim()) : null;
|
||||
|
||||
/// hamlib names the modes CW, USB, LSB, RTTY, FM, AM and PKTUSB.
|
||||
private static string RigctldMode(Mode mode) => mode.Name switch
|
||||
{
|
||||
"CW" => "CW",
|
||||
"USB" => "USB",
|
||||
"LSB" => "LSB",
|
||||
"AM" => "AM",
|
||||
"FM" => "FM",
|
||||
"RTTY" => "RTTY",
|
||||
_ => "PKTUSB",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user