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? Moved; public event EventHandler? 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 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 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 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", }; }