Files
Nonemm/src/Nonemm.Rig/RigctldRadio.cs
T
erikbrockoandClaude Opus 5 b5043506ae Fill in the macros from N1MM's function key page
Going down N1MM's published list, the ones a contest operator would miss:

| | |
|---|---|
| `{LASTEXCH}` | what the last station sent, built from the boxes the contest asks for |
| `{ROVERQTH}` | where a rover is now |
| `{TIME}`, `{DATE}`, `{TIMEGMT}`, `{DATEGMT}` | the clock and the calendar |
| `{ESC}`, `{CTRL-A}` to `{CTRL-Z}` | the control characters a TNC reads as commands |
| `{CTRLHOME}` | the cursor back in the callsign box |
| `{F1}` to `{F12}` | another of this radio's keys, where `{CTRLF1}` is the other radio's |
| `{CQ}` | makes any key a CQ key: it starts running and carries alternating CQ on |
| `{DIGQTCS}`, `{DIGQTCR}` | opens the WAE QTC window the way asked for |
| `{CLEARRIT}` | the receive offset back to zero, which is rigctld's `J 0` |
| `{FORCELOG}` | logs what is in the boxes, holes and all |

`{FORCELOG}` is the one that gives something up. `LogContact` refuses an entry
that is short or holds a value the contest will not take, so that a caller who
has not checked cannot log nonsense; `LogContactAnyway` is the way past it,
because a contact in the log with a hole in it can be filled in afterwards and
one that was never logged cannot. It still needs a callsign, and the status
line says the row will need fixing before the log goes in.

A key that names itself would send for ever, so a key sending another key
ignores the macro a second time down.

What is still passed over is written down in the README and unfinished.md.
Advanced voicing is the one that matters: N1MM says a callsign or a serial by
playing a recording per letter out of `LettersFiles`, which is why `!` and `#`
say nothing on phone here and why F5 is empty in N1MM's own SSB file too. The
three packet BBS date formats are passed over because there is nothing here to
check a guess at them against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
2026-09-11 08:24:34 +00:00

222 lines
7.6 KiB
C#

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,
int number = 1,
TimeSpan? pollInterval = null,
TimeSpan? retryInterval = null)
{
this.host = host;
this.port = port;
Number = number;
this.pollInterval = pollInterval ?? TimeSpan.FromMilliseconds(200);
this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(3);
}
public int Number { get; }
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);
/// The transmit frequency has to be set before split is turned on, or a
/// radio that was last split somewhere else transmits there.
public async Task SetSplitAsync(
Frequency? transmitFrequency,
CancellationToken cancellation = default)
{
if (transmitFrequency is null)
{
await SendAsync("S 0 VFOA", cancellation).ConfigureAwait(false);
return;
}
await SendAsync($"I {transmitFrequency.Value.Hertz}", cancellation).ConfigureAwait(false);
await SendAsync("S 1 VFOB", cancellation).ConfigureAwait(false);
}
/// `J` is rigctld's set_rit, in hertz. Zero is the offset turned off,
/// which is what the radio's own RIT clear button does.
public async Task ClearRitAsync(CancellationToken cancellation = default) =>
await SendAsync("J 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)
{
RigctldReply frequency = await AskAsync("f", cancellation).ConfigureAwait(false);
RigctldReply mode = await AskAsync("m", cancellation).ConfigureAwait(false);
if (frequency.Number("Frequency") is > 0 and long hertz)
{
RadioState state = new(
Frequency.FromHertz(hertz),
Modes.Parse(mode.Value("Mode") ?? "") ?? State?.Mode ?? Modes.Cw)
{
TransmitFrequency = await TransmitFrequencyAsync(cancellation).ConfigureAwait(false),
};
if (state != State)
{
State = state;
Moved?.Invoke(this, state);
}
}
await Task.Delay(pollInterval, cancellation).ConfigureAwait(false);
}
}
/// Zero when the radio is not split, and when it cannot tell us.
private async Task<Frequency> TransmitFrequencyAsync(CancellationToken cancellation)
{
RigctldReply split = await AskAsync("s", cancellation).ConfigureAwait(false);
if (!split.IsOk || split.Number("Split") is not 1)
{
return Frequency.Zero;
}
RigctldReply transmit = await AskAsync("i", cancellation).ConfigureAwait(false);
return transmit.Number("TX Frequency") is > 0 and long hertz
? Frequency.FromHertz(hertz)
: Frequency.Zero;
}
/// Every command goes out with a `+` in front, which asks rigctld for the
/// extended answer: named fields ended by an RPRT line. Without the
/// terminator a client has to know how many lines each command returns, and
/// one wrong count desynchronises the connection for good.
private async Task<RigctldReply> 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 = [];
while (true)
{
string line = await reader.ReadLineAsync(cancellation).ConfigureAwait(false)
?? throw new IOException($"rigctld closed the connection during '{command}'");
lines.Add(line);
if (line.StartsWith("RPRT", StringComparison.Ordinal))
{
return RigctldReply.Parse(lines);
}
}
}
finally
{
writing.Release();
}
}
private async Task SendAsync(string command, CancellationToken cancellation)
{
if (!IsConnected)
{
return;
}
try
{
await AskAsync(command, cancellation).ConfigureAwait(false);
}
catch (Exception e) when (e is IOException or SocketException)
{
SetConnected(false);
}
}
private void SetConnected(bool connected)
{
if (IsConnected != connected)
{
IsConnected = connected;
ConnectionChanged?.Invoke(this, connected);
}
}
/// 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",
};
}