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:
@@ -18,7 +18,8 @@ namespace Nonemm.App;
|
||||
public sealed class AppSession : IDisposable
|
||||
{
|
||||
private LogStore? store;
|
||||
private RigctldRadio? radio;
|
||||
private readonly List<RigctldRadio> radios = [];
|
||||
private int activeRadio;
|
||||
private ClusterClient? cluster;
|
||||
private StationNetwork? network;
|
||||
private MessageSender? keyer;
|
||||
@@ -52,7 +53,14 @@ public sealed class AppSession : IDisposable
|
||||
|
||||
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;
|
||||
|
||||
@@ -107,7 +115,10 @@ public sealed class AppSession : IDisposable
|
||||
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
|
||||
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
|
||||
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.Logged += (_, qso) => Bandmap.Add(new Spot(
|
||||
qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
|
||||
@@ -214,22 +225,74 @@ public sealed class AppSession : IDisposable
|
||||
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();
|
||||
radio = new RigctldRadio(Settings.RigctldHost, Settings.RigctldPort);
|
||||
radio.Moved += (_, state) => Logging?.Tune(state.Frequency, state.Mode);
|
||||
radio.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||
radio.Start();
|
||||
DisposeRadios();
|
||||
int number = 1;
|
||||
foreach (StoredRadio configured in Settings.Radios.Where(r => r.IsEnabled))
|
||||
{
|
||||
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();
|
||||
radio = null;
|
||||
DisposeRadios();
|
||||
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()
|
||||
{
|
||||
cluster?.Dispose();
|
||||
@@ -265,7 +328,7 @@ public sealed class AppSession : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
radio?.Dispose();
|
||||
DisposeRadios();
|
||||
cluster?.Dispose();
|
||||
network?.Dispose();
|
||||
keyer?.Dispose();
|
||||
|
||||
@@ -21,9 +21,15 @@ public sealed record Settings
|
||||
|
||||
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; }
|
||||
|
||||
@@ -66,7 +72,8 @@ public sealed record Settings
|
||||
}
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), Json) ?? new Settings();
|
||||
return Migrated(
|
||||
JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), Json) ?? new Settings());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -78,6 +85,33 @@ public sealed record Settings
|
||||
|
||||
public void Save(string path) =>
|
||||
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
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.RadioDialog"
|
||||
Title="Radio" Width="420" SizeToContent="Height"
|
||||
Title="Radios" Width="460" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner" CanResize="False">
|
||||
<StackPanel Margin="14" Spacing="6">
|
||||
<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" />
|
||||
<Grid ColumnDefinitions="*,8,110" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Text="Host" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="HostBox" Grid.Row="1" />
|
||||
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
|
||||
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="70,*,8,90" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto"
|
||||
Margin="0,8,0,0">
|
||||
<TextBlock Grid.Column="1" Text="Host" FontSize="11" Opacity="0.7" />
|
||||
<TextBlock Grid.Column="3" Text="Port" FontSize="11" Opacity="0.7" />
|
||||
|
||||
<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>
|
||||
<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">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
|
||||
@@ -4,26 +4,46 @@ using Nonemm.App.Configuration;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
/// The radios on the desk, one rigctld each.
|
||||
public sealed partial class RadioDialog : Window
|
||||
{
|
||||
private const int DefaultPort = 4532;
|
||||
|
||||
private readonly Settings settings;
|
||||
|
||||
public RadioDialog(Settings settings)
|
||||
{
|
||||
this.settings = settings;
|
||||
InitializeComponent();
|
||||
HostBox.Text = settings.RigctldHost;
|
||||
PortBox.Text = settings.RigctldPort.ToString();
|
||||
EnabledBox.IsChecked = settings.RadioEnabled;
|
||||
Show(0, HostBox, PortBox, EnabledBox);
|
||||
Show(1, SecondHostBox, SecondPortBox, SecondEnabledBox);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
RigctldHost = (HostBox.Text ?? "127.0.0.1").Trim(),
|
||||
RigctldPort = int.TryParse(PortBox.Text, out int port) ? port : 4532,
|
||||
RadioEnabled = EnabledBox.IsChecked == true,
|
||||
Radios =
|
||||
[
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Rig;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
@@ -63,16 +64,35 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
||||
View.InvalidateVisual();
|
||||
}
|
||||
|
||||
/// Only the receiver is known. The radio does not report the transmit VFO
|
||||
/// or a second radio yet, so those bars are not drawn; `VfoRole` has the
|
||||
/// roles ready for when it does.
|
||||
private IReadOnlyList<VfoMarker> Vfos() =>
|
||||
session.Logging is null
|
||||
? []
|
||||
: [VfoMarker.Centred(
|
||||
session.Logging.Frequency,
|
||||
Modes.Bandwidth(session.Logging.Mode),
|
||||
VfoRole.Receive)];
|
||||
/// Green for where the operator is listening, red for where the radio
|
||||
/// transmits when working split, orange for the other radio of a two-radio
|
||||
/// station.
|
||||
private IReadOnlyList<VfoMarker> Vfos()
|
||||
{
|
||||
if (session.Logging is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
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.
|
||||
private (Frequency Low, Frequency High) Window(Band band, Frequency centre)
|
||||
|
||||
@@ -257,16 +257,13 @@ public sealed partial class EntryWindow
|
||||
return;
|
||||
}
|
||||
session.Save(updated);
|
||||
if (updated.RadioEnabled)
|
||||
session.ConnectRadios();
|
||||
Status(session.Radios.Count switch
|
||||
{
|
||||
session.ConnectRadio();
|
||||
Status($"radio: rigctld at {updated.RigctldHost}:{updated.RigctldPort}");
|
||||
}
|
||||
else
|
||||
{
|
||||
session.DisconnectRadio();
|
||||
Status("radio disconnected");
|
||||
}
|
||||
0 => "no radio",
|
||||
1 => $"radio: rigctld at {updated.Radios[0].Host}:{updated.Radios[0].Port}",
|
||||
_ => $"{session.Radios.Count} radios · Ctrl+Tab moves between them",
|
||||
});
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -175,6 +175,11 @@ public sealed partial class EntryWindow : Window
|
||||
SyncBoxes();
|
||||
boxes[0].Focus();
|
||||
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():
|
||||
e.Handled = true;
|
||||
MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
|
||||
@@ -284,7 +289,9 @@ public sealed partial class EntryWindow : Window
|
||||
VerdictText.Text = "";
|
||||
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;
|
||||
RunText.Text = Logging.IsRunning ? "RUN" : "S&P";
|
||||
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(
|
||||
(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 · " +
|
||||
$"{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)
|
||||
|
||||
Reference in New Issue
Block a user