Two entry windows and an SO2R box

LoggingSession mixed two things: the contest, which one station has one of,
and what the operator is typing, which belongs to a radio. It is now
ContestSession and RadioPosition. Two positions share one log, one score and
one run of serial numbers, so a station worked on radio 1 is a dupe on radio 2.
Frequency, mode, split, what is typed and whether you are running belong to
each radio on its own.

A second radio opens a second entry window. It has no menu of its own: it is
another view of the same contest, not another program. Only the window for
radio 1 opens the database and the connections.

Ctrl+Tab moves the operator to the other radio and the keyboard follows.
Ctrl+Shift+Tab puts both radios in the headphones.

OtrspBox speaks OTRSP to an SO2R box over a serial port: TX1/TX2 for the key,
RX1/RX2 and RX1S for the headphones, AUXnnn for an output line. It follows the
active radio, and every message points the box at this radio before keying, so
a message cannot go out of the radio the operator has just left. A command that
would change nothing is not sent, because the box works relays; N1MM does the
same. The box takes a Stream, so what it sends is tested without a serial port.

Still missing: alternating CQ, and voice keying on the second radio.

Looked at under Xvfb with two fake radios, one of them split: both windows up,
radio 1 showing 14008.00 into 14020.0, radio 2 on 14030.00, Ctrl+Tab moving the
keyboard between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 16:49:24 +00:00
parent 43be3816c3
commit 1185faed78
22 changed files with 714 additions and 208 deletions

View File

@@ -19,10 +19,12 @@ public sealed class AppSession : IDisposable
{
private LogStore? store;
private readonly List<RigctldRadio> radios = [];
private readonly List<RadioPosition> positions = [];
private int activeRadio;
private ClusterClient? cluster;
private StationNetwork? network;
private MessageSender? keyer;
private So2rBox? box;
public AppSession(UserPaths paths, Settings settings)
{
@@ -49,7 +51,16 @@ public sealed class AppSession : IDisposable
public Bandmap Bandmap { get; } = new();
public LoggingSession? Logging { get; private set; }
/// The contest in progress: one log and one score, however many radios.
public ContestSession? Logging { get; private set; }
/// One per radio, in radio-number order. There is always at least one, so
/// the program works with no radio connected.
public IReadOnlyList<RadioPosition> Positions => positions;
/// The radio the operator is on.
public RadioPosition? Position =>
activeRadio < positions.Count ? positions[activeRadio] : null;
public CheckWindowSources? Check { get; private set; }
@@ -60,7 +71,7 @@ public sealed class AppSession : IDisposable
/// 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 int ActiveRadioNumber => Position?.RadioNumber ?? 1;
public ClusterClient? Cluster => cluster;
@@ -68,6 +79,13 @@ public sealed class AppSession : IDisposable
public MessageSender? Keyer => keyer;
/// The SO2R box, or null when there is none and the operator switches the
/// transmitter and the headphones by hand.
public So2rBox? Box => box;
/// True while both radios are in the headphones.
public bool IsListeningToBoth { get; private set; }
public event EventHandler? Changed;
public event EventHandler? ContestChanged;
@@ -115,10 +133,13 @@ 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 ContestSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries);
positions.Clear();
for (int number = 1; number <= PositionCount; number++)
{
RadioNumber = ActiveRadioNumber,
};
positions.Add(new RadioPosition(Logging, number));
}
activeRadio = Math.Min(activeRadio, positions.Count - 1);
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
@@ -126,11 +147,14 @@ public sealed class AppSession : IDisposable
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign);
Check = new CheckWindowSources(Logging, Calls, Bandmap);
Check = new CheckWindowSources(positions[0], Calls, Bandmap);
Save(Settings with { ContestNumber = contestNumber });
ContestChanged?.Invoke(this, EventArgs.Empty);
}
/// One entry position per radio, and one when there is no radio at all.
private int PositionCount => Math.Max(1, Settings.Radios.Count(r => r.IsEnabled));
/// Starts, restarts or stops the link to the other stations of a
/// multi-operator entry, following what the settings now say.
public void ApplyNetworkSettings()
@@ -240,7 +264,12 @@ public sealed class AppSession : IDisposable
radios.Add(opened);
opened.Start();
}
activeRadio = Math.Min(activeRadio, Math.Max(0, radios.Count - 1));
if (Logging is not null && positions.Count != PositionCount)
{
OpenContest(Logging.Instance.ContestNumber);
return;
}
activeRadio = Math.Min(activeRadio, Math.Max(0, positions.Count - 1));
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -254,34 +283,71 @@ public sealed class AppSession : IDisposable
/// that radio is sitting, and contacts are logged against its number.
public void SwapRadio()
{
if (radios.Count < 2)
if (positions.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);
}
}
activeRadio = (activeRadio + 1) % positions.Count;
_ = FollowActiveRadioAsync();
Changed?.Invoke(this, EventArgs.Empty);
ActiveRadioChanged?.Invoke(this, ActiveRadioNumber);
}
/// Puts both radios in the headphones, or goes back to one. An operator
/// listens to the second radio while the first is sending.
public void ToggleListenToBoth()
{
IsListeningToBoth = !IsListeningToBoth;
_ = FollowActiveRadioAsync();
Changed?.Invoke(this, EventArgs.Empty);
}
/// Points the box at the radio about to transmit. Called before keying, so
/// a message never goes out of the radio the operator has just left.
public Task PointTransmitAtAsync(int radioNumber) =>
box?.SetTransmitAsync(radioNumber) ?? Task.CompletedTask;
/// Starts, restarts or stops the SO2R box, following what the settings say.
public void ApplySo2rSettings()
{
box?.Dispose();
box = Settings.So2rBoxPort.Length > 0 ? OtrspBox.Open(Settings.So2rBoxPort) : null;
_ = FollowActiveRadioAsync();
Changed?.Invoke(this, EventArgs.Empty);
}
private async Task FollowActiveRadioAsync()
{
if (box is null)
{
return;
}
try
{
await box.SetTransmitAsync(ActiveRadioNumber).ConfigureAwait(false);
await box.SetReceiveAsync(ActiveRadioNumber, IsListeningToBoth).ConfigureAwait(false);
}
catch (Exception e) when (e is IOException or InvalidOperationException)
{
box.Dispose();
box = null;
}
}
/// Raised when the operator moves to the other radio, so the entry windows
/// can hand the keyboard over and the SO2R box can follow.
public event EventHandler<int>? ActiveRadioChanged;
/// 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)
RadioPosition? position = positions.FirstOrDefault(p => p.RadioNumber == moved.Number);
if (position is null)
{
Logging?.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
}
else
{
Changed?.Invoke(this, EventArgs.Empty);
return;
}
position.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
}
private void DisposeRadios()
@@ -332,6 +398,7 @@ public sealed class AppSession : IDisposable
cluster?.Dispose();
network?.Dispose();
keyer?.Dispose();
box?.Dispose();
store?.Dispose();
}