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:
@@ -19,6 +19,7 @@
|
|||||||
<Project Path="tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj" />
|
<Project Path="tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj" />
|
||||||
<Project Path="tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj" />
|
<Project Path="tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj" />
|
||||||
<Project Path="tests/Nonemm.Keying.Tests/Nonemm.Keying.Tests.csproj" />
|
<Project Path="tests/Nonemm.Keying.Tests/Nonemm.Keying.Tests.csproj" />
|
||||||
|
<Project Path="tests/Nonemm.Rig.Tests/Nonemm.Rig.Tests.csproj" />
|
||||||
<Project Path="tests/Nonemm.Network.Tests/Nonemm.Network.Tests.csproj" />
|
<Project Path="tests/Nonemm.Network.Tests/Nonemm.Network.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -46,7 +46,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
|
|||||||
| While typing | dupe check, multiplier check, points, country and zone from the country file |
|
| While typing | dupe check, multiplier check, points, country and zone from the country file |
|
||||||
| Windows | entry, log, check, bandmap, score summary, packet |
|
| Windows | entry, log, check, bandmap, score summary, packet |
|
||||||
| Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations |
|
| Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations |
|
||||||
| Radio | hamlib `rigctld`, reconnecting on its own |
|
| Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own |
|
||||||
| Cluster | DX cluster over telnet, spots feeding the bandmap, Alt+P to spot a station |
|
| Cluster | DX cluster over telnet, spots feeding the bandmap, Alt+P to spot a station |
|
||||||
| Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines |
|
| Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines |
|
||||||
| Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message |
|
| Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message |
|
||||||
@@ -120,6 +120,36 @@ Correcting the country prefix by hand changes the score. The country file is a
|
|||||||
best guess for calls it has no rule for, so what the contact says now wins over
|
best guess for calls it has no rule for, so what the contact says now wins over
|
||||||
what the file says.
|
what the file says.
|
||||||
|
|
||||||
|
### Radios
|
||||||
|
|
||||||
|
**Config → Radios** takes a `rigctld` address per radio. Each radio needs its
|
||||||
|
own `rigctld`, started for whichever rig is on that port:
|
||||||
|
|
||||||
|
rigctld -m 2028 -r /dev/ttyUSB0 -t 4532
|
||||||
|
rigctld -m 1035 -r /dev/ttyUSB1 -t 4533
|
||||||
|
|
||||||
|
Commands go out with a `+` in front, which asks `rigctld` for its extended
|
||||||
|
answer: named fields ended by an `RPRT` line. The raw answer is bare values with
|
||||||
|
no terminator, so the client has to know how many lines each command returns,
|
||||||
|
and one wrong count leaves the connection reading every later answer against
|
||||||
|
the wrong command.
|
||||||
|
|
||||||
|
Split is read from the radio and recorded: the contact stores where we
|
||||||
|
transmitted in N1MM's QSX column, the entry window shows `14008.00 ▸ 14020.0`,
|
||||||
|
and the bandmap draws a red bar at the transmit frequency. A radio that cannot
|
||||||
|
do split answers `RPRT -11`; that is an answer, not a broken connection, and
|
||||||
|
everything else it reported still counts.
|
||||||
|
|
||||||
|
A second radio makes the station SO2R. Both are read and both show on the
|
||||||
|
bandmap — the one you are on in green, the other in orange — but only the one
|
||||||
|
you are on drives the entry window, so the second radio moving cannot drag you
|
||||||
|
off the station you are working. Ctrl+Tab moves you to the other radio; contacts
|
||||||
|
record which one made them.
|
||||||
|
|
||||||
|
Not there yet: a second entry window, alternating CQ, and audio switching. What
|
||||||
|
is here is two radios read and logged correctly, not a full two-radio operating
|
||||||
|
position.
|
||||||
|
|
||||||
### The bandmap
|
### The bandmap
|
||||||
|
|
||||||
A frequency scale down the left with the stations written out beside it. Each
|
A frequency scale down the left with the stations written out beside it. Each
|
||||||
@@ -134,10 +164,9 @@ Callsigns are coloured by the same scorer as the entry window, so a dupe reads
|
|||||||
as a dupe here too. Clicking a callsign puts the radio there with the call
|
as a dupe here too. Clicking a callsign puts the radio there with the call
|
||||||
already in the entry window; clicking anywhere else just moves the radio.
|
already in the entry window; clicking anywhere else just moves the radio.
|
||||||
|
|
||||||
The green bar on the scale is the receiver, as wide as the mode it is in.
|
The bars on the scale are as wide as the mode passes: green where you are
|
||||||
`VfoRole` has transmit and second-radio bars ready, but nothing feeds them yet:
|
listening, red where the radio transmits when working split, orange for the
|
||||||
the radio does not report a transmit VFO or a second radio, so those bars stay
|
other radio of a two-radio station.
|
||||||
out rather than showing a guess.
|
|
||||||
|
|
||||||
Band-plan colouring of the scale is not there. The segments differ by ITU region
|
Band-plan colouring of the scale is not there. The segments differ by ITU region
|
||||||
and the program has no band-plan table, so it would be guesswork.
|
and the program has no band-plan table, so it would be guesswork.
|
||||||
@@ -210,7 +239,9 @@ beyond logging them, and the check window's Call History and Exchange columns,
|
|||||||
which are left out rather than shown empty.
|
which are left out rather than shown empty.
|
||||||
|
|
||||||
The radio, network and keyer clients are tested against fakes that speak the
|
The radio, network and keyer clients are tested against fakes that speak the
|
||||||
documented protocols. None has been run against a real radio or keyer. The
|
documented protocols — the radio one over a real socket, against a stand-in for
|
||||||
|
`rigctld` that answers in the extended form. None has been run against a real
|
||||||
|
radio or keyer. The
|
||||||
cluster client is tested over a real socket against a node fake that sends the
|
cluster client is tested over a real socket against a node fake that sends the
|
||||||
telnet negotiation, the login prompt and spot lines, but not against a live
|
telnet negotiation, the login prompt and spot lines, but not against a live
|
||||||
node.
|
node.
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ namespace Nonemm.App;
|
|||||||
public sealed class AppSession : IDisposable
|
public sealed class AppSession : IDisposable
|
||||||
{
|
{
|
||||||
private LogStore? store;
|
private LogStore? store;
|
||||||
private RigctldRadio? radio;
|
private readonly List<RigctldRadio> radios = [];
|
||||||
|
private int activeRadio;
|
||||||
private ClusterClient? cluster;
|
private ClusterClient? cluster;
|
||||||
private StationNetwork? network;
|
private StationNetwork? network;
|
||||||
private MessageSender? keyer;
|
private MessageSender? keyer;
|
||||||
@@ -52,7 +53,14 @@ public sealed class AppSession : IDisposable
|
|||||||
|
|
||||||
public CheckWindowSources? Check { get; private set; }
|
public CheckWindowSources? Check { get; private set; }
|
||||||
|
|
||||||
public Radio? Radio => radio;
|
/// One entry per configured radio, in radio-number order.
|
||||||
|
public IReadOnlyList<Radio> Radios => radios;
|
||||||
|
|
||||||
|
/// The radio the operator is on. Null when none is configured, and then
|
||||||
|
/// frequency and mode stay wherever they were last typed.
|
||||||
|
public Radio? Radio => activeRadio < radios.Count ? radios[activeRadio] : null;
|
||||||
|
|
||||||
|
public int ActiveRadioNumber => Radio?.Number ?? 1;
|
||||||
|
|
||||||
public ClusterClient? Cluster => cluster;
|
public ClusterClient? Cluster => cluster;
|
||||||
|
|
||||||
@@ -107,7 +115,10 @@ public sealed class AppSession : IDisposable
|
|||||||
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
|
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
|
||||||
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
|
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
|
||||||
SaveDefinition(contest);
|
SaveDefinition(contest);
|
||||||
Logging = new LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries);
|
Logging = new LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries)
|
||||||
|
{
|
||||||
|
RadioNumber = ActiveRadioNumber,
|
||||||
|
};
|
||||||
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||||
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
|
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
|
||||||
qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
|
qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
|
||||||
@@ -214,22 +225,74 @@ public sealed class AppSession : IDisposable
|
|||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ConnectRadio()
|
/// Opens a connection per enabled radio. A second one makes the station
|
||||||
|
/// SO2R: both are read, but only the one the operator is on drives the
|
||||||
|
/// entry window.
|
||||||
|
public void ConnectRadios()
|
||||||
{
|
{
|
||||||
radio?.Dispose();
|
DisposeRadios();
|
||||||
radio = new RigctldRadio(Settings.RigctldHost, Settings.RigctldPort);
|
int number = 1;
|
||||||
radio.Moved += (_, state) => Logging?.Tune(state.Frequency, state.Mode);
|
foreach (StoredRadio configured in Settings.Radios.Where(r => r.IsEnabled))
|
||||||
radio.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
{
|
||||||
radio.Start();
|
RigctldRadio opened = new(configured.Host, configured.Port, number++);
|
||||||
|
opened.Moved += (_, state) => RadioMoved(opened, state);
|
||||||
|
opened.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
radios.Add(opened);
|
||||||
|
opened.Start();
|
||||||
|
}
|
||||||
|
activeRadio = Math.Min(activeRadio, Math.Max(0, radios.Count - 1));
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DisconnectRadio()
|
public void DisconnectRadios()
|
||||||
{
|
{
|
||||||
radio?.Dispose();
|
DisposeRadios();
|
||||||
radio = null;
|
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Moves the operator to the other radio. The entry window follows where
|
||||||
|
/// that radio is sitting, and contacts are logged against its number.
|
||||||
|
public void SwapRadio()
|
||||||
|
{
|
||||||
|
if (radios.Count < 2)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeRadio = (activeRadio + 1) % radios.Count;
|
||||||
|
if (Logging is not null)
|
||||||
|
{
|
||||||
|
Logging.RadioNumber = ActiveRadioNumber;
|
||||||
|
if (Radio?.State is { } state)
|
||||||
|
{
|
||||||
|
Logging.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The radio the operator is not on still moves, and the bandmap shows it,
|
||||||
|
/// but it must not drag the entry window off the contact being worked.
|
||||||
|
private void RadioMoved(Radio moved, RadioState state)
|
||||||
|
{
|
||||||
|
if (moved.Number == ActiveRadioNumber)
|
||||||
|
{
|
||||||
|
Logging?.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisposeRadios()
|
||||||
|
{
|
||||||
|
foreach (RigctldRadio open in radios)
|
||||||
|
{
|
||||||
|
open.Dispose();
|
||||||
|
}
|
||||||
|
radios.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
public void ConnectCluster()
|
public void ConnectCluster()
|
||||||
{
|
{
|
||||||
cluster?.Dispose();
|
cluster?.Dispose();
|
||||||
@@ -265,7 +328,7 @@ public sealed class AppSession : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
radio?.Dispose();
|
DisposeRadios();
|
||||||
cluster?.Dispose();
|
cluster?.Dispose();
|
||||||
network?.Dispose();
|
network?.Dispose();
|
||||||
keyer?.Dispose();
|
keyer?.Dispose();
|
||||||
|
|||||||
@@ -21,9 +21,15 @@ public sealed record Settings
|
|||||||
|
|
||||||
public IReadOnlyList<string> ClusterCommands { get; init; } = [];
|
public IReadOnlyList<string> ClusterCommands { get; init; } = [];
|
||||||
|
|
||||||
public string RigctldHost { get; init; } = "127.0.0.1";
|
/// One entry per radio, in radio-number order. A second radio makes the
|
||||||
|
/// station SO2R.
|
||||||
|
public IReadOnlyList<StoredRadio> Radios { get; init; } = [];
|
||||||
|
|
||||||
public int RigctldPort { get; init; } = 4532;
|
/// Written by versions that only knew one radio. Read once, to fill
|
||||||
|
/// `Radios` in, and never written again.
|
||||||
|
public string RigctldHost { get; init; } = "";
|
||||||
|
|
||||||
|
public int RigctldPort { get; init; }
|
||||||
|
|
||||||
public bool RadioEnabled { get; init; }
|
public bool RadioEnabled { get; init; }
|
||||||
|
|
||||||
@@ -66,7 +72,8 @@ public sealed record Settings
|
|||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), Json) ?? new Settings();
|
return Migrated(
|
||||||
|
JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), Json) ?? new Settings());
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
@@ -78,6 +85,33 @@ public sealed record Settings
|
|||||||
|
|
||||||
public void Save(string path) =>
|
public void Save(string path) =>
|
||||||
File.WriteAllText(path, JsonSerializer.Serialize(this, Json));
|
File.WriteAllText(path, JsonSerializer.Serialize(this, Json));
|
||||||
|
|
||||||
|
/// Carries the single radio an older settings file holds into the list.
|
||||||
|
private static Settings Migrated(Settings settings) =>
|
||||||
|
settings.Radios.Count > 0 || settings.RigctldHost.Length == 0
|
||||||
|
? settings
|
||||||
|
: settings with
|
||||||
|
{
|
||||||
|
Radios = [new StoredRadio
|
||||||
|
{
|
||||||
|
Host = settings.RigctldHost,
|
||||||
|
Port = settings.RigctldPort,
|
||||||
|
IsEnabled = settings.RadioEnabled,
|
||||||
|
}],
|
||||||
|
RigctldHost = "",
|
||||||
|
RigctldPort = 0,
|
||||||
|
RadioEnabled = false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One radio's `rigctld`.
|
||||||
|
public sealed record StoredRadio
|
||||||
|
{
|
||||||
|
public string Host { get; init; } = "127.0.0.1";
|
||||||
|
|
||||||
|
public int Port { get; init; } = 4532;
|
||||||
|
|
||||||
|
public bool IsEnabled { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The operator's station as it is stored, kept separate from `StationInfo` so
|
/// The operator's station as it is stored, kept separate from `StationInfo` so
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="Nonemm.App.Dialogs.RadioDialog"
|
x:Class="Nonemm.App.Dialogs.RadioDialog"
|
||||||
Title="Radio" Width="420" SizeToContent="Height"
|
Title="Radios" Width="460" SizeToContent="Height"
|
||||||
WindowStartupLocation="CenterOwner" CanResize="False">
|
WindowStartupLocation="CenterOwner" CanResize="False">
|
||||||
<StackPanel Margin="14" Spacing="6">
|
<StackPanel Margin="14" Spacing="6">
|
||||||
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||||
Text="The logger reads and tunes the radio through hamlib's rigctld, which is started separately for whichever radio is on the desk, for example: rigctld -m 2028 -r /dev/ttyUSB0" />
|
Text="The logger reads and tunes each radio through its own hamlib rigctld, started separately for whichever radio is on the desk, for example: rigctld -m 2028 -r /dev/ttyUSB0 -t 4532" />
|
||||||
<Grid ColumnDefinitions="*,8,110" RowDefinitions="Auto,Auto">
|
<Grid ColumnDefinitions="70,*,8,90" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto"
|
||||||
<TextBlock Text="Host" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
Margin="0,8,0,0">
|
||||||
<TextBox Name="HostBox" Grid.Row="1" />
|
<TextBlock Grid.Column="1" Text="Host" FontSize="11" Opacity="0.7" />
|
||||||
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
<TextBlock Grid.Column="3" Text="Port" FontSize="11" Opacity="0.7" />
|
||||||
<TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
|
|
||||||
|
<TextBlock Grid.Row="1" Text="Radio 1" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Row="1" Grid.Column="1" Name="HostBox" />
|
||||||
|
<TextBox Grid.Row="1" Grid.Column="3" Name="PortBox" />
|
||||||
|
<CheckBox Grid.Row="2" Grid.Column="1" Name="EnabledBox" Content="Follow this radio"
|
||||||
|
Margin="0,2,0,8" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Text="Radio 2" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Row="3" Grid.Column="1" Name="SecondHostBox" />
|
||||||
|
<TextBox Grid.Row="3" Grid.Column="3" Name="SecondPortBox" />
|
||||||
|
<CheckBox Grid.Row="4" Grid.Column="1" Name="SecondEnabledBox" Content="Follow this radio"
|
||||||
|
Margin="0,2,0,0" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<CheckBox Name="EnabledBox" Content="Follow the radio" Margin="0,6,0,0" />
|
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75" Margin="0,8,0,0"
|
||||||
|
Text="With a second radio the station is SO2R: both are read and both show on the bandmap, but only the one you are on drives the entry window. Ctrl+Tab moves you to the other." />
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
||||||
<Button Content="Cancel" Click="OnCancel" />
|
<Button Content="Cancel" Click="OnCancel" />
|
||||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||||
|
|||||||
@@ -4,26 +4,46 @@ using Nonemm.App.Configuration;
|
|||||||
|
|
||||||
namespace Nonemm.App.Dialogs;
|
namespace Nonemm.App.Dialogs;
|
||||||
|
|
||||||
|
/// The radios on the desk, one rigctld each.
|
||||||
public sealed partial class RadioDialog : Window
|
public sealed partial class RadioDialog : Window
|
||||||
{
|
{
|
||||||
|
private const int DefaultPort = 4532;
|
||||||
|
|
||||||
private readonly Settings settings;
|
private readonly Settings settings;
|
||||||
|
|
||||||
public RadioDialog(Settings settings)
|
public RadioDialog(Settings settings)
|
||||||
{
|
{
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
HostBox.Text = settings.RigctldHost;
|
Show(0, HostBox, PortBox, EnabledBox);
|
||||||
PortBox.Text = settings.RigctldPort.ToString();
|
Show(1, SecondHostBox, SecondPortBox, SecondEnabledBox);
|
||||||
EnabledBox.IsChecked = settings.RadioEnabled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void Show(int at, TextBox host, TextBox port, CheckBox enabled)
|
||||||
|
{
|
||||||
|
StoredRadio radio = at < settings.Radios.Count
|
||||||
|
? settings.Radios[at]
|
||||||
|
: new StoredRadio { Port = DefaultPort + at };
|
||||||
|
host.Text = radio.Host;
|
||||||
|
port.Text = radio.Port.ToString();
|
||||||
|
enabled.IsChecked = radio.IsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
|
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
|
||||||
{
|
{
|
||||||
RigctldHost = (HostBox.Text ?? "127.0.0.1").Trim(),
|
Radios =
|
||||||
RigctldPort = int.TryParse(PortBox.Text, out int port) ? port : 4532,
|
[
|
||||||
RadioEnabled = EnabledBox.IsChecked == true,
|
Read(HostBox, PortBox, EnabledBox),
|
||||||
|
Read(SecondHostBox, SecondPortBox, SecondEnabledBox),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private static StoredRadio Read(TextBox host, TextBox port, CheckBox enabled) => new()
|
||||||
|
{
|
||||||
|
Host = (host.Text ?? "127.0.0.1").Trim(),
|
||||||
|
Port = int.TryParse(port.Text, out int number) ? number : DefaultPort,
|
||||||
|
IsEnabled = enabled.IsChecked == true,
|
||||||
|
};
|
||||||
|
|
||||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Avalonia.Media;
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using Nonemm.Contests;
|
using Nonemm.Contests;
|
||||||
using Nonemm.Core;
|
using Nonemm.Core;
|
||||||
|
using Nonemm.Rig;
|
||||||
using Nonemm.Spotting;
|
using Nonemm.Spotting;
|
||||||
|
|
||||||
namespace Nonemm.App.Windows;
|
namespace Nonemm.App.Windows;
|
||||||
@@ -63,16 +64,35 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
|||||||
View.InvalidateVisual();
|
View.InvalidateVisual();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only the receiver is known. The radio does not report the transmit VFO
|
/// Green for where the operator is listening, red for where the radio
|
||||||
/// or a second radio yet, so those bars are not drawn; `VfoRole` has the
|
/// transmits when working split, orange for the other radio of a two-radio
|
||||||
/// roles ready for when it does.
|
/// station.
|
||||||
private IReadOnlyList<VfoMarker> Vfos() =>
|
private IReadOnlyList<VfoMarker> Vfos()
|
||||||
session.Logging is null
|
{
|
||||||
? []
|
if (session.Logging is null)
|
||||||
: [VfoMarker.Centred(
|
{
|
||||||
session.Logging.Frequency,
|
return [];
|
||||||
Modes.Bandwidth(session.Logging.Mode),
|
}
|
||||||
VfoRole.Receive)];
|
Frequency width = Modes.Bandwidth(session.Logging.Mode);
|
||||||
|
List<VfoMarker> markers =
|
||||||
|
[
|
||||||
|
VfoMarker.Centred(session.Logging.Frequency, width, VfoRole.Receive),
|
||||||
|
];
|
||||||
|
if (session.Logging.TransmitFrequency.Hertz > 0)
|
||||||
|
{
|
||||||
|
markers.Add(VfoMarker.Centred(
|
||||||
|
session.Logging.TransmitFrequency, width, VfoRole.Transmit));
|
||||||
|
}
|
||||||
|
foreach (Radio other in session.Radios.Where(r => r.Number != session.ActiveRadioNumber))
|
||||||
|
{
|
||||||
|
if (other.State is { } state)
|
||||||
|
{
|
||||||
|
markers.Add(VfoMarker.Centred(
|
||||||
|
state.Frequency, Modes.Bandwidth(state.Mode), VfoRole.Second));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
|
|
||||||
/// The slice to show, centred on the receiver and kept inside the band.
|
/// The slice to show, centred on the receiver and kept inside the band.
|
||||||
private (Frequency Low, Frequency High) Window(Band band, Frequency centre)
|
private (Frequency Low, Frequency High) Window(Band band, Frequency centre)
|
||||||
|
|||||||
@@ -257,16 +257,13 @@ public sealed partial class EntryWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.Save(updated);
|
session.Save(updated);
|
||||||
if (updated.RadioEnabled)
|
session.ConnectRadios();
|
||||||
|
Status(session.Radios.Count switch
|
||||||
{
|
{
|
||||||
session.ConnectRadio();
|
0 => "no radio",
|
||||||
Status($"radio: rigctld at {updated.RigctldHost}:{updated.RigctldPort}");
|
1 => $"radio: rigctld at {updated.Radios[0].Host}:{updated.Radios[0].Port}",
|
||||||
}
|
_ => $"{session.Radios.Count} radios · Ctrl+Tab moves between them",
|
||||||
else
|
});
|
||||||
{
|
|
||||||
session.DisconnectRadio();
|
|
||||||
Status("radio disconnected");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnClusterSettings(object? sender, RoutedEventArgs e)
|
private async void OnClusterSettings(object? sender, RoutedEventArgs e)
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ public sealed partial class EntryWindow : Window
|
|||||||
|
|
||||||
private IEnumerable<(string What, Action Start)> Connections()
|
private IEnumerable<(string What, Action Start)> Connections()
|
||||||
{
|
{
|
||||||
if (session.Settings.RadioEnabled)
|
if (session.Settings.Radios.Any(r => r.IsEnabled))
|
||||||
{
|
{
|
||||||
yield return ("radio", session.ConnectRadio);
|
yield return ("radios", session.ConnectRadios);
|
||||||
}
|
}
|
||||||
if (session.Settings.ClusterEnabled)
|
if (session.Settings.ClusterEnabled)
|
||||||
{
|
{
|
||||||
@@ -175,6 +175,11 @@ public sealed partial class EntryWindow : Window
|
|||||||
SyncBoxes();
|
SyncBoxes();
|
||||||
boxes[0].Focus();
|
boxes[0].Focus();
|
||||||
break;
|
break;
|
||||||
|
case Key.Tab when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||||
|
e.Handled = true;
|
||||||
|
session.SwapRadio();
|
||||||
|
Status($"radio {session.ActiveRadioNumber}");
|
||||||
|
break;
|
||||||
case Key.Tab when FocusedIsEntryBox():
|
case Key.Tab when FocusedIsEntryBox():
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
|
MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
|
||||||
@@ -284,7 +289,9 @@ public sealed partial class EntryWindow : Window
|
|||||||
VerdictText.Text = "";
|
VerdictText.Text = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
FrequencyText.Text = Logging.Frequency.Kilohertz.ToString("0.00");
|
FrequencyText.Text = Logging.TransmitFrequency.Hertz > 0
|
||||||
|
? $"{Logging.Frequency.Kilohertz:0.00} ▸ {Logging.TransmitFrequency.Kilohertz:0.0}"
|
||||||
|
: Logging.Frequency.Kilohertz.ToString("0.00");
|
||||||
ModeText.Text = Logging.Mode.Name;
|
ModeText.Text = Logging.Mode.Name;
|
||||||
RunText.Text = Logging.IsRunning ? "RUN" : "S&P";
|
RunText.Text = Logging.IsRunning ? "RUN" : "S&P";
|
||||||
RunBorder.Background = Logging.IsRunning ? Verdicts.Worth : new SolidColorBrush(Color.FromArgb(0x22, 0x80, 0x80, 0x80));
|
RunBorder.Background = Logging.IsRunning ? Verdicts.Worth : new SolidColorBrush(Color.FromArgb(0x22, 0x80, 0x80, 0x80));
|
||||||
@@ -310,8 +317,9 @@ public sealed partial class EntryWindow : Window
|
|||||||
" ",
|
" ",
|
||||||
Logging.Contest.MultiplierNames.Select(
|
Logging.Contest.MultiplierNames.Select(
|
||||||
(name, at) => $"{name} {Logging.Log.Tally.MultiplierCount(at + 1)}"));
|
(name, at) => $"{name} {Logging.Log.Tally.MultiplierCount(at + 1)}"));
|
||||||
|
string radio = session.Radios.Count > 1 ? $" · radio {session.ActiveRadioNumber}" : "";
|
||||||
return $"{Logging.Contest.DisplayName} · {Logging.Log.Tally.Qsos} Q · " +
|
return $"{Logging.Contest.DisplayName} · {Logging.Log.Tally.Qsos} Q · " +
|
||||||
$"{Logging.Log.Tally.Points} pts · {mults} · {Logging.Log.TotalScore:N0}";
|
$"{Logging.Log.Tally.Points} pts · {mults} · {Logging.Log.TotalScore:N0}{radio}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private string VerdictLine(Verdict? verdict)
|
private string VerdictLine(Verdict? verdict)
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ namespace Nonemm.Rig;
|
|||||||
/// here is allowed to do nothing.
|
/// here is allowed to do nothing.
|
||||||
public interface Radio : IDisposable
|
public interface Radio : IDisposable
|
||||||
{
|
{
|
||||||
|
/// 1 or 2. A two-radio station records which one made each contact.
|
||||||
|
int Number { get; }
|
||||||
|
|
||||||
bool IsConnected { get; }
|
bool IsConnected { get; }
|
||||||
|
|
||||||
RadioState? State { get; }
|
RadioState? State { get; }
|
||||||
@@ -20,4 +23,8 @@ public interface Radio : IDisposable
|
|||||||
Task TuneAsync(Frequency frequency, CancellationToken cancellation = default);
|
Task TuneAsync(Frequency frequency, CancellationToken cancellation = default);
|
||||||
|
|
||||||
Task SetModeAsync(Mode mode, CancellationToken cancellation = default);
|
Task SetModeAsync(Mode mode, CancellationToken cancellation = default);
|
||||||
|
|
||||||
|
/// Null turns split off. Not every radio can do this; one that cannot says
|
||||||
|
/// so and the logger carries on.
|
||||||
|
Task SetSplitAsync(Frequency? transmitFrequency, CancellationToken cancellation = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,12 @@ using Nonemm.Core;
|
|||||||
namespace Nonemm.Rig;
|
namespace Nonemm.Rig;
|
||||||
|
|
||||||
/// Where the radio is now.
|
/// Where the radio is now.
|
||||||
public sealed record RadioState(Frequency Frequency, Mode Mode);
|
public sealed record RadioState(Frequency Frequency, Mode Mode)
|
||||||
|
{
|
||||||
|
/// Where the transmitter is when working split, and zero when it is not.
|
||||||
|
/// Split is not a separate flag: a transmit frequency of zero means the
|
||||||
|
/// radio transmits where it listens.
|
||||||
|
public Frequency TransmitFrequency { get; init; } = Frequency.Zero;
|
||||||
|
|
||||||
|
public bool IsSplit => TransmitFrequency.Hertz > 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using Nonemm.Core;
|
using Nonemm.Core;
|
||||||
|
|
||||||
@@ -24,15 +23,19 @@ public sealed class RigctldRadio : Radio
|
|||||||
public RigctldRadio(
|
public RigctldRadio(
|
||||||
string host = "127.0.0.1",
|
string host = "127.0.0.1",
|
||||||
int port = 4532,
|
int port = 4532,
|
||||||
|
int number = 1,
|
||||||
TimeSpan? pollInterval = null,
|
TimeSpan? pollInterval = null,
|
||||||
TimeSpan? retryInterval = null)
|
TimeSpan? retryInterval = null)
|
||||||
{
|
{
|
||||||
this.host = host;
|
this.host = host;
|
||||||
this.port = port;
|
this.port = port;
|
||||||
|
Number = number;
|
||||||
this.pollInterval = pollInterval ?? TimeSpan.FromMilliseconds(200);
|
this.pollInterval = pollInterval ?? TimeSpan.FromMilliseconds(200);
|
||||||
this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(3);
|
this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int Number { get; }
|
||||||
|
|
||||||
public bool IsConnected { get; private set; }
|
public bool IsConnected { get; private set; }
|
||||||
|
|
||||||
public RadioState? State { get; private set; }
|
public RadioState? State { get; private set; }
|
||||||
@@ -49,6 +52,21 @@ public sealed class RigctldRadio : Radio
|
|||||||
public async Task SetModeAsync(Mode mode, CancellationToken cancellation = default) =>
|
public async Task SetModeAsync(Mode mode, CancellationToken cancellation = default) =>
|
||||||
await SendAsync($"M {RigctldMode(mode)} 0", cancellation).ConfigureAwait(false);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
stopping.Cancel();
|
stopping.Cancel();
|
||||||
@@ -93,11 +111,16 @@ public sealed class RigctldRadio : Radio
|
|||||||
{
|
{
|
||||||
while (!cancellation.IsCancellationRequested)
|
while (!cancellation.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
Frequency? frequency = ReadFrequency(await AskAsync("f", cancellation).ConfigureAwait(false));
|
RigctldReply frequency = await AskAsync("f", cancellation).ConfigureAwait(false);
|
||||||
Mode? mode = ReadMode(await AskAsync("m", cancellation).ConfigureAwait(false));
|
RigctldReply mode = await AskAsync("m", cancellation).ConfigureAwait(false);
|
||||||
if (frequency is not null)
|
if (frequency.Number("Frequency") is > 0 and long hertz)
|
||||||
{
|
{
|
||||||
RadioState state = new(frequency.Value, mode ?? State?.Mode ?? Modes.Cw);
|
RadioState state = new(
|
||||||
|
Frequency.FromHertz(hertz),
|
||||||
|
Modes.Parse(mode.Value("Mode") ?? "") ?? State?.Mode ?? Modes.Cw)
|
||||||
|
{
|
||||||
|
TransmitFrequency = await TransmitFrequencyAsync(cancellation).ConfigureAwait(false),
|
||||||
|
};
|
||||||
if (state != State)
|
if (state != State)
|
||||||
{
|
{
|
||||||
State = state;
|
State = state;
|
||||||
@@ -108,7 +131,25 @@ public sealed class RigctldRadio : Radio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<string>> AskAsync(string command, CancellationToken cancellation)
|
/// 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);
|
await writing.WaitAsync(cancellation).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
@@ -117,21 +158,18 @@ public sealed class RigctldRadio : Radio
|
|||||||
{
|
{
|
||||||
throw new IOException("the connection to rigctld is not open");
|
throw new IOException("the connection to rigctld is not open");
|
||||||
}
|
}
|
||||||
await writer.WriteAsync(command + "\n").ConfigureAwait(false);
|
await writer.WriteAsync("+" + command + "\n").ConfigureAwait(false);
|
||||||
List<string> lines = [];
|
List<string> lines = [];
|
||||||
// rigctld answers one value per line and ends with RPRT on an error
|
while (true)
|
||||||
string? line = await reader.ReadLineAsync(cancellation).ConfigureAwait(false)
|
{
|
||||||
|
string line = await reader.ReadLineAsync(cancellation).ConfigureAwait(false)
|
||||||
?? throw new IOException($"rigctld closed the connection during '{command}'");
|
?? throw new IOException($"rigctld closed the connection during '{command}'");
|
||||||
lines.Add(line);
|
lines.Add(line);
|
||||||
if (command == "m")
|
if (line.StartsWith("RPRT", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
string? passband = await reader.ReadLineAsync(cancellation).ConfigureAwait(false);
|
return RigctldReply.Parse(lines);
|
||||||
if (passband is not null)
|
|
||||||
{
|
|
||||||
lines.Add(passband);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lines;
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -145,24 +183,14 @@ public sealed class RigctldRadio : Radio
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await writing.WaitAsync(cancellation).ConfigureAwait(false);
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (writer is null || reader is null)
|
await AskAsync(command, cancellation).ConfigureAwait(false);
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await writer.WriteAsync(command + "\n").ConfigureAwait(false);
|
|
||||||
await reader.ReadLineAsync(cancellation).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
catch (Exception e) when (e is IOException or SocketException)
|
catch (Exception e) when (e is IOException or SocketException)
|
||||||
{
|
{
|
||||||
SetConnected(false);
|
SetConnected(false);
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
writing.Release();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetConnected(bool connected)
|
private void SetConnected(bool connected)
|
||||||
@@ -174,16 +202,6 @@ public sealed class RigctldRadio : Radio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
/// hamlib names the modes CW, USB, LSB, RTTY, FM, AM and PKTUSB.
|
||||||
private static string RigctldMode(Mode mode) => mode.Name switch
|
private static string RigctldMode(Mode mode) => mode.Name switch
|
||||||
{
|
{
|
||||||
|
|||||||
58
src/Nonemm.Rig/RigctldReply.cs
Normal file
58
src/Nonemm.Rig/RigctldReply.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace Nonemm.Rig;
|
||||||
|
|
||||||
|
/// One answer from `rigctld` in its extended form. A command sent with a `+`
|
||||||
|
/// in front is answered with named fields and a closing `RPRT` line:
|
||||||
|
///
|
||||||
|
/// get_split_vfo:
|
||||||
|
/// Split: 1
|
||||||
|
/// TX VFO: VFOB
|
||||||
|
/// RPRT 0
|
||||||
|
///
|
||||||
|
/// The raw form answers with bare values and no terminator, so a client has to
|
||||||
|
/// know how many lines each command returns. Get that wrong once and every
|
||||||
|
/// later answer is read against the wrong command for the rest of the session.
|
||||||
|
public sealed record RigctldReply(int Result, IReadOnlyDictionary<string, string> Fields)
|
||||||
|
{
|
||||||
|
/// hamlib returns 0 for success and a negative code for anything else. A
|
||||||
|
/// radio that cannot do what was asked answers -11, which is not a reason
|
||||||
|
/// to drop the connection.
|
||||||
|
public bool IsOk => Result == 0;
|
||||||
|
|
||||||
|
public string? Value(string field) =>
|
||||||
|
Fields.TryGetValue(field, out string? value) ? value : null;
|
||||||
|
|
||||||
|
public long? Number(string field) =>
|
||||||
|
Value(field) is { } text
|
||||||
|
&& long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long number)
|
||||||
|
? number
|
||||||
|
: null;
|
||||||
|
|
||||||
|
public static RigctldReply Parse(IEnumerable<string> lines)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> fields = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
int result = 0;
|
||||||
|
foreach (string line in lines)
|
||||||
|
{
|
||||||
|
if (line.StartsWith("RPRT", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
result = int.TryParse(
|
||||||
|
line[4..].Trim(),
|
||||||
|
NumberStyles.Integer,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out int code)
|
||||||
|
? code
|
||||||
|
: -1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int colon = line.IndexOf(':');
|
||||||
|
// the first line echoes the command and carries no value
|
||||||
|
if (colon > 0 && colon < line.Length - 1)
|
||||||
|
{
|
||||||
|
fields[line[..colon].Trim()] = line[(colon + 1)..].Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new RigctldReply(result, fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,8 +48,14 @@ public sealed class LoggingSession
|
|||||||
/// With no radio connected this stays where it was last typed.
|
/// With no radio connected this stays where it was last typed.
|
||||||
public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
|
public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
|
||||||
|
|
||||||
|
/// Where the radio transmits when working split, and zero when it is not.
|
||||||
|
public Frequency TransmitFrequency { get; private set; } = Frequency.Zero;
|
||||||
|
|
||||||
public Mode Mode { get; private set; } = Modes.Cw;
|
public Mode Mode { get; private set; } = Modes.Cw;
|
||||||
|
|
||||||
|
/// Which radio of a two-radio station the operator is on.
|
||||||
|
public int RadioNumber { get; set; } = 1;
|
||||||
|
|
||||||
public int SentNumber { get; private set; }
|
public int SentNumber { get; private set; }
|
||||||
|
|
||||||
public string Operator { get; set; } = "";
|
public string Operator { get; set; } = "";
|
||||||
@@ -67,10 +73,11 @@ public sealed class LoggingSession
|
|||||||
|
|
||||||
public event EventHandler<Qso>? Deleted;
|
public event EventHandler<Qso>? Deleted;
|
||||||
|
|
||||||
public void Tune(Frequency frequency, Mode? mode = null)
|
public void Tune(Frequency frequency, Mode? mode = null, Frequency? transmitFrequency = null)
|
||||||
{
|
{
|
||||||
Frequency = frequency;
|
Frequency = frequency;
|
||||||
Mode = mode ?? Mode;
|
Mode = mode ?? Mode;
|
||||||
|
TransmitFrequency = transmitFrequency ?? TransmitFrequency;
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +175,9 @@ public sealed class LoggingSession
|
|||||||
TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)),
|
TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)),
|
||||||
Call = parsed,
|
Call = parsed,
|
||||||
Frequency = Frequency,
|
Frequency = Frequency,
|
||||||
|
QsxFrequency = TransmitFrequency,
|
||||||
Mode = Mode,
|
Mode = Mode,
|
||||||
|
RadioNumber = RadioNumber,
|
||||||
ContestName = Contest.Name,
|
ContestName = Contest.Name,
|
||||||
ContestNumber = Instance.ContestNumber,
|
ContestNumber = Instance.ContestNumber,
|
||||||
SentReport = DefaultReport(),
|
SentReport = DefaultReport(),
|
||||||
|
|||||||
89
tests/Nonemm.Rig.Tests/FakeRigctld.cs
Normal file
89
tests/Nonemm.Rig.Tests/FakeRigctld.cs
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
tests/Nonemm.Rig.Tests/Nonemm.Rig.Tests.csproj
Normal file
25
tests/Nonemm.Rig.Tests/Nonemm.Rig.Tests.csproj
Normal 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>
|
||||||
110
tests/Nonemm.Rig.Tests/RigctldRadioTests.cs
Normal file
110
tests/Nonemm.Rig.Tests/RigctldRadioTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
59
tests/Nonemm.Rig.Tests/RigctldReplyTests.cs
Normal file
59
tests/Nonemm.Rig.Tests/RigctldReplyTests.cs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,6 +163,45 @@ public class LoggingSessionTests
|
|||||||
Assert.True(session.Log.Qsos.Single().IsMultiplier1);
|
Assert.True(session.Log.Qsos.Single().IsMultiplier1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AContactWorkedSplitRecordsWhereWeTransmitted()
|
||||||
|
{
|
||||||
|
LoggingSession session = Session();
|
||||||
|
session.Tune(
|
||||||
|
Frequency.FromKilohertz(14_025),
|
||||||
|
Modes.Cw,
|
||||||
|
Frequency.FromKilohertz(14_200));
|
||||||
|
Type(session, "JA1XYZ", "25");
|
||||||
|
|
||||||
|
Qso logged = session.LogContact();
|
||||||
|
|
||||||
|
Assert.Equal(14_025_000, logged.Frequency.Hertz);
|
||||||
|
Assert.Equal(14_200_000, logged.QsxFrequency.Hertz);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AContactRecordsWhichRadioMadeIt()
|
||||||
|
{
|
||||||
|
LoggingSession session = Session();
|
||||||
|
session.RadioNumber = 2;
|
||||||
|
Type(session, "JA1XYZ", "25");
|
||||||
|
|
||||||
|
Assert.Equal(2, session.LogContact().RadioNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tuning without saying anything about split leaves it where it was, so a
|
||||||
|
/// frequency typed by hand does not clear what the radio reported.
|
||||||
|
[Fact]
|
||||||
|
public void TuningWithoutASplitLeavesTheOneAlreadySet()
|
||||||
|
{
|
||||||
|
LoggingSession session = Session();
|
||||||
|
session.Tune(Frequency.FromKilohertz(14_025), null, Frequency.FromKilohertz(14_200));
|
||||||
|
|
||||||
|
session.Tune(Frequency.FromKilohertz(14_030));
|
||||||
|
|
||||||
|
Assert.Equal(14_200_000, session.TransmitFrequency.Hertz);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EditingAContactRescoresTheLog()
|
public void EditingAContactRescoresTheLog()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user