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

@@ -43,18 +43,18 @@ public sealed partial class BandmapWindow : RefreshableWindow
public override void Refresh()
{
session.Bandmap.DropOlderThan(DateTime.UtcNow);
Band? band = session.Logging is null ? null : Bands.ForFrequency(session.Logging.Frequency);
Band? band = session.Position is null ? null : Bands.ForFrequency(session.Position.Frequency);
BandText.Text = band?.Name ?? "no band";
if (session.Logging is null || band is null)
if (session.Position is null || band is null)
{
View.Spots = [];
View.Vfos = [];
StatusText.Text = session.Logging is null ? "no contest is open" : "off band";
StatusText.Text = session.Position is null ? "no contest is open" : "off band";
View.InvalidateVisual();
return;
}
(Frequency low, Frequency high) = Window(band, session.Logging.Frequency);
(Frequency low, Frequency high) = Window(band, session.Position.Frequency);
View.Low = low;
View.High = high;
View.TickStep = StepFor(high.Hertz - low.Hertz);
@@ -69,19 +69,19 @@ public sealed partial class BandmapWindow : RefreshableWindow
/// station.
private IReadOnlyList<VfoMarker> Vfos()
{
if (session.Logging is null)
if (session.Position is null)
{
return [];
}
Frequency width = Modes.Bandwidth(session.Logging.Mode);
Frequency width = Modes.Bandwidth(session.Position.Mode);
List<VfoMarker> markers =
[
VfoMarker.Centred(session.Logging.Frequency, width, VfoRole.Receive),
VfoMarker.Centred(session.Position.Frequency, width, VfoRole.Receive),
];
if (session.Logging.TransmitFrequency.Hertz > 0)
if (session.Position.TransmitFrequency.Hertz > 0)
{
markers.Add(VfoMarker.Centred(
session.Logging.TransmitFrequency, width, VfoRole.Transmit));
session.Position.TransmitFrequency, width, VfoRole.Transmit));
}
foreach (Radio other in session.Radios.Where(r => r.Number != session.ActiveRadioNumber))
{
@@ -119,18 +119,18 @@ public sealed partial class BandmapWindow : RefreshableWindow
private Verdict? VerdictFor(Spot spot)
{
if (session.Logging is null)
if (session.Position is null)
{
return null;
}
return session.Logging.Log.Judge(new Qso
return session.Position.Log.Judge(new Qso
{
Id = "",
TimestampUtc = DateTime.UtcNow,
Call = spot.Call,
Frequency = spot.Frequency,
Mode = session.Logging.Mode,
ContestName = session.Logging.Contest.Name,
Mode = session.Position.Mode,
ContestName = session.Position.Contest.Name,
});
}
}

View File

@@ -184,7 +184,7 @@ public sealed partial class EntryWindow
Status("there is nothing in the log yet");
return;
}
await new EditContactDialog(Logging, Logging.Log.Qsos[^1].Id).ShowDialog(this);
await new EditContactDialog(Logging.Session, Logging.Log.Qsos[^1].Id).ShowDialog(this);
}
/// Puts the call being typed on the cluster, or the last one logged when
@@ -258,6 +258,15 @@ public sealed partial class EntryWindow
}
session.Save(updated);
session.ConnectRadios();
try
{
session.ApplySo2rSettings();
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException)
{
Status($"could not open the SO2R box: {error.Message}");
return;
}
Status(session.Radios.Count switch
{
0 => "no radio",

View File

@@ -25,7 +25,7 @@
</Window.Styles>
<DockPanel>
<Menu DockPanel.Dock="Top">
<Menu Name="MainMenu" DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Header="_New Database…" Click="OnNewDatabase" />
<MenuItem Header="_Open Database…" Click="OnOpenDatabase" />

View File

@@ -15,30 +15,54 @@ namespace Nonemm.App.Windows;
public sealed partial class EntryWindow : Window
{
private readonly AppSession session;
private readonly int radioNumber;
private EntryWindow? secondRadio;
private readonly List<TextBox> boxes = [];
private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly Dictionary<Type, Window> openWindows = [];
private bool updating;
public EntryWindow(AppSession session)
public EntryWindow(AppSession session, int radioNumber = 1)
{
this.session = session;
this.radioNumber = radioNumber;
InitializeComponent();
BuildFunctionKeys();
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(BuildEntryBoxes);
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(() =>
{
BuildEntryBoxes();
ShowSecondRadio();
});
clock.Tick += (_, _) => UpdateClock();
clock.Start();
session.ActiveRadioChanged += (_, number) =>
Dispatcher.UIThread.Post(() => FollowActiveRadio(number));
// only the window for radio 1 opens the log and the connections; the
// second window is another view of the same session
Opened += (_, _) => Reopen();
if (radioNumber > 1)
{
Title = "Nonemm — radio 2";
MainMenu.IsVisible = false;
}
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
}
private LoggingSession? Logging => session.Logging;
/// Resolved every time rather than held, because opening a contest builds
/// new positions.
private RadioPosition? Logging =>
session.Positions.FirstOrDefault(p => p.RadioNumber == radioNumber);
/// Reopens the database and contest the operator was last in.
private void Reopen()
{
if (radioNumber > 1)
{
BuildEntryBoxes();
return;
}
try
{
int contestNumber = session.Settings.ContestNumber;
@@ -57,6 +81,42 @@ public sealed partial class EntryWindow : Window
}
StartConnections();
BuildEntryBoxes();
ShowSecondRadio();
}
/// A second radio gets its own entry window: the two share one log and one
/// score, but what is typed on each belongs to that radio.
private void ShowSecondRadio()
{
if (radioNumber > 1)
{
return;
}
bool wanted = session.Positions.Count > 1;
if (wanted && secondRadio is null)
{
Title = "Nonemm — radio 1";
secondRadio = new EntryWindow(session, 2);
secondRadio.Closed += (_, _) => secondRadio = null;
secondRadio.Show(this);
}
else if (!wanted && secondRadio is not null)
{
Title = "Nonemm";
secondRadio.Close();
secondRadio = null;
}
}
/// The keyboard follows the operator to the other radio.
private void FollowActiveRadio(int number)
{
if (number != radioNumber || Logging is null)
{
return;
}
Activate();
boxes.ElementAtOrDefault(Logging.Entry.Focus)?.Focus();
}
/// Brings up whatever the operator had connected last time. Each is
@@ -94,6 +154,10 @@ public sealed partial class EntryWindow : Window
{
yield return ("keyer", session.ApplyKeyerSettings);
}
if (session.Settings.So2rBoxPort.Length > 0)
{
yield return ("SO2R box", session.ApplySo2rSettings);
}
}
private void BuildEntryBoxes()
@@ -175,10 +239,15 @@ public sealed partial class EntryWindow : Window
SyncBoxes();
boxes[0].Focus();
break;
case Key.Tab when e.KeyModifiers.HasFlag(KeyModifiers.Control)
&& e.KeyModifiers.HasFlag(KeyModifiers.Shift):
e.Handled = true;
session.ToggleListenToBoth();
Status(session.IsListeningToBoth ? "both radios" : $"radio {session.ActiveRadioNumber}");
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;
@@ -392,6 +461,8 @@ public sealed partial class EntryWindow : Window
return;
}
string text = MessageExpander.Expand(template, Logging);
// the box has to point at this radio before the key does
_ = session.PointTransmitAtAsync(radioNumber);
Status($"sending {text}");
_ = SendAsync(text);
}

View File

@@ -58,7 +58,7 @@ public sealed partial class LogWindow : RefreshableWindow
message = "";
}
private static string Summary(LoggingSession logging) =>
private static string Summary(ContestSession logging) =>
$"{logging.Log.Tally.Qsos} contacts · {logging.Log.Tally.Points} points · " +
$"{logging.Log.Tally.TotalMultipliers} multipliers · score {logging.Log.TotalScore:N0}";