Read split from the radio, and read two of them

Split. RadioState carries a transmit frequency, zero meaning the radio
transmits where it listens, so split is not a second flag that can disagree
with it. The contact stores it in N1MM's QSX column, the entry window shows
14008.00 followed by the transmit frequency, and the bandmap draws a red bar
there. SetSplitAsync sets the frequency before turning split on, or a radio
last split somewhere else transmits there.

Every command now goes out with a + in front, asking rigctld for its extended
answer: named fields ended by an RPRT line. The raw answer is bare values with
no terminator, so the client had to know how many lines each command returns —
there was a `command == "m"` special case for the one that returns two. Add a
third such command and get the count wrong once, and every later answer is read
against the wrong command for the rest of the session. RigctldReply parses the
extended form and is tested on its own.

A radio that cannot do split answers RPRT -11. That is an answer, not a broken
connection, so the frequency and mode it did report still count.

Two radios. Settings hold a list rather than one host and port, with the single
radio an older settings file holds carried into it. Both radios are read and
both show on the bandmap, the active one green and the other orange, but only
the active one drives the entry window: the second radio moving must not drag
the operator off the station being worked. Ctrl+Tab swaps, and a contact
records which radio made it.

This is not a full two-radio operating position — no second entry window, no
alternating CQ, no audio switching. It is two radios read and logged correctly.

Tested against a stand-in for rigctld over a real socket, and looked at under
Xvfb with two fake radios, one of them split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 16:40:42 +00:00
parent da9884ebf5
commit 43be3816c3
19 changed files with 706 additions and 98 deletions

View File

@@ -0,0 +1,89 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace Nonemm.Rig.Tests;
/// A stand-in for hamlib's `rigctld`, answering in the extended form the client
/// asks for. It records what it was told so a test can check the commands that
/// went out, not only what came back.
public sealed class FakeRigctld : IDisposable
{
private readonly TcpListener listener;
private readonly CancellationTokenSource stopping = new();
private readonly List<string> received = [];
private readonly Lock guard = new();
public FakeRigctld()
{
listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
_ = Task.Run(() => ServeAsync(stopping.Token));
}
public int Port => ((IPEndPoint)listener.LocalEndpoint).Port;
public long FrequencyHertz { get; set; } = 14_025_000;
public string Mode { get; set; } = "CW";
public bool IsSplit { get; set; }
public long TransmitFrequencyHertz { get; set; }
/// -11 is what hamlib returns for something the radio cannot do.
public bool SupportsSplit { get; set; } = true;
public IReadOnlyList<string> Received
{
get
{
lock (guard)
{
return [.. received];
}
}
}
public void Dispose()
{
stopping.Cancel();
listener.Stop();
stopping.Dispose();
}
private async Task ServeAsync(CancellationToken cancellation)
{
try
{
using TcpClient client = await listener.AcceptTcpClientAsync(cancellation);
using StreamReader reader = new(client.GetStream(), Encoding.ASCII);
using StreamWriter writer = new(client.GetStream(), Encoding.ASCII) { AutoFlush = true };
while (await reader.ReadLineAsync(cancellation) is { } line)
{
lock (guard)
{
received.Add(line);
}
await writer.WriteAsync(Answer(line.TrimStart('+').Trim()));
}
}
catch (Exception e) when (e is OperationCanceledException or IOException or SocketException)
{
}
}
private string Answer(string command)
{
string verb = command.Split(' ')[0];
return verb switch
{
"f" => $"get_freq:\nFrequency: {FrequencyHertz}\nRPRT 0\n",
"m" => $"get_mode:\nMode: {Mode}\nPassband: 500\nRPRT 0\n",
"s" when !SupportsSplit => "RPRT -11\n",
"s" => $"get_split_vfo:\nSplit: {(IsSplit ? 1 : 0)}\nTX VFO: VFOB\nRPRT 0\n",
"i" => $"get_split_freq:\nTX Frequency: {TransmitFrequencyHertz}\nRPRT 0\n",
_ => $"{verb}: {command}\nRPRT 0\n",
};
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Nonemm.Rig\Nonemm.Rig.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,110 @@
using Nonemm.Core;
namespace Nonemm.Rig.Tests;
/// The client driven over a real socket against a stand-in for rigctld.
public class RigctldRadioTests
{
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5);
private static RigctldRadio Radio(FakeRigctld node) =>
new("127.0.0.1", node.Port, pollInterval: TimeSpan.FromMilliseconds(20));
private static async Task<RadioState> FirstStateAsync(RigctldRadio radio)
{
TaskCompletionSource<RadioState> moved = new();
radio.Moved += (_, state) => moved.TrySetResult(state);
radio.Start();
return await moved.Task.WaitAsync(Patience);
}
[Fact]
public async Task TheRadioReportsWhereItIs()
{
using FakeRigctld node = new() { FrequencyHertz = 21_005_000, Mode = "USB" };
using RigctldRadio radio = Radio(node);
RadioState state = await FirstStateAsync(radio);
Assert.Equal(21_005_000, state.Frequency.Hertz);
Assert.Equal(Modes.Usb, state.Mode);
Assert.False(state.IsSplit);
}
[Fact]
public async Task ASplitRadioReportsWhereItTransmits()
{
using FakeRigctld node = new() { IsSplit = true, TransmitFrequencyHertz = 14_200_000 };
using RigctldRadio radio = Radio(node);
RadioState state = await FirstStateAsync(radio);
Assert.True(state.IsSplit);
Assert.Equal(14_200_000, state.TransmitFrequency.Hertz);
}
/// A radio that cannot do split answers -11. That is not a broken
/// connection, and the frequency it did report still counts.
[Fact]
public async Task ARadioThatCannotDoSplitIsStillRead()
{
using FakeRigctld node = new() { SupportsSplit = false };
using RigctldRadio radio = Radio(node);
RadioState state = await FirstStateAsync(radio);
Assert.Equal(14_025_000, state.Frequency.Hertz);
Assert.False(state.IsSplit);
}
/// The transmit frequency goes out before split is turned on, or a radio
/// last split somewhere else transmits there.
[Fact]
public async Task TurningSplitOnSetsTheFrequencyFirst()
{
using FakeRigctld node = new();
using RigctldRadio radio = Radio(node);
await FirstStateAsync(radio);
await radio.SetSplitAsync(Frequency.FromKilohertz(14_200));
IReadOnlyList<string> sent = [.. node.Received.Where(c => c.StartsWith("+I") || c.StartsWith("+S"))];
Assert.Equal(["+I 14200000", "+S 1 VFOB"], sent);
}
[Fact]
public async Task TurningSplitOffSaysSo()
{
using FakeRigctld node = new();
using RigctldRadio radio = Radio(node);
await FirstStateAsync(radio);
await radio.SetSplitAsync(null);
Assert.Contains("+S 0 VFOA", node.Received);
}
[Fact]
public async Task TuningSendsTheFrequencyInHertz()
{
using FakeRigctld node = new();
using RigctldRadio radio = Radio(node);
await FirstStateAsync(radio);
await radio.TuneAsync(Frequency.FromKilohertz(7_025.5));
Assert.Contains("+F 7025500", node.Received);
}
/// Every command carries the + that asks for the answer with an RPRT
/// terminator, so the reader can never fall a line behind.
[Fact]
public async Task EveryCommandAsksForTheExtendedAnswer()
{
using FakeRigctld node = new();
using RigctldRadio radio = Radio(node);
await FirstStateAsync(radio);
Assert.All(node.Received, command => Assert.StartsWith("+", command));
}
}

View File

@@ -0,0 +1,59 @@
namespace Nonemm.Rig.Tests;
public class RigctldReplyTests
{
[Fact]
public void FieldsAreReadOffAnExtendedAnswer()
{
RigctldReply reply = RigctldReply.Parse([
"get_split_vfo:",
"Split: 1",
"TX VFO: VFOB",
"RPRT 0",
]);
Assert.True(reply.IsOk);
Assert.Equal(1, reply.Number("Split"));
Assert.Equal("VFOB", reply.Value("TX VFO"));
}
/// A radio that cannot do what was asked answers -11. That is an answer,
/// not a broken connection.
[Fact]
public void ARadioThatCannotDoItAnswersWithACode()
{
RigctldReply reply = RigctldReply.Parse(["RPRT -11"]);
Assert.False(reply.IsOk);
Assert.Equal(-11, reply.Result);
Assert.Null(reply.Number("Split"));
}
[Fact]
public void TheEchoedCommandIsNotAField()
{
RigctldReply reply = RigctldReply.Parse(["get_freq:", "Frequency: 14025000", "RPRT 0"]);
Assert.Equal(14_025_000, reply.Number("Frequency"));
Assert.Null(reply.Value("get_freq"));
}
[Fact]
public void ASetCommandEchoesItsArgumentAndReportsSuccess()
{
RigctldReply reply = RigctldReply.Parse(["set_freq: 14025000", "RPRT 0"]);
Assert.True(reply.IsOk);
Assert.Equal(14_025_000, reply.Number("set_freq"));
}
[Fact]
public void ATextValueThatIsNotANumberReadsBackAsNull()
{
RigctldReply reply = RigctldReply.Parse(["get_mode:", "Mode: CW", "Passband: 500", "RPRT 0"]);
Assert.Equal("CW", reply.Value("Mode"));
Assert.Null(reply.Number("Mode"));
Assert.Equal(500, reply.Number("Passband"));
}
}