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:
Erik
2026-08-27 10:39:53 +00:00
co-authored by Claude Opus 5
parent ef631f1dc4
commit ffeeb2cdc1
25 changed files with 1416 additions and 0 deletions
+198
View File
@@ -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",
};
}