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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user