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();
}

View File

@@ -43,6 +43,10 @@ public sealed record Settings
public IReadOnlyList<string> NetworkPeers { get; init; } = [];
/// The serial port of an SO2R box speaking OTRSP, or empty for none. The
/// box routes the transmitter and the headphones between the two radios.
public string So2rBoxPort { get; init; } = "";
/// `none`, `cwdaemon` or `winkeyer`.
public string KeyerKind { get; init; } = "none";

View File

@@ -30,11 +30,11 @@ public sealed partial class EditContactDialog : Window
QsoField.CountryPrefix, QsoField.WpxPrefix,
];
private readonly LoggingSession logging;
private readonly ContestSession logging;
private readonly Dictionary<QsoField, TextBox> boxes = [];
private int at;
public EditContactDialog(LoggingSession logging, string contactId)
public EditContactDialog(ContestSession logging, string contactId)
{
this.logging = logging;
InitializeComponent();

View File

@@ -23,8 +23,11 @@
<CheckBox Grid.Row="4" Grid.Column="1" Name="SecondEnabledBox" Content="Follow this radio"
Margin="0,2,0,0" />
</Grid>
<TextBlock Text="SO2R box serial port, or empty for none" FontSize="11" Opacity="0.7"
Margin="0,10,0,1" />
<TextBox Name="BoxPortBox" />
<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." />
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, and Ctrl+Shift+Tab puts both in the headphones. An OTRSP box follows, routing the key and the headphones." />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
<Button Content="Cancel" Click="OnCancel" />
<Button Content="Save" Click="OnSave" IsDefault="True" />

View File

@@ -17,6 +17,7 @@ public sealed partial class RadioDialog : Window
InitializeComponent();
Show(0, HostBox, PortBox, EnabledBox);
Show(1, SecondHostBox, SecondPortBox, SecondEnabledBox);
BoxPortBox.Text = settings.So2rBoxPort;
}
private void Show(int at, TextBox host, TextBox port, CheckBox enabled)
@@ -36,6 +37,7 @@ public sealed partial class RadioDialog : Window
Read(HostBox, PortBox, EnabledBox),
Read(SecondHostBox, SecondPortBox, SecondEnabledBox),
],
So2rBoxPort = (BoxPortBox.Text ?? "").Trim(),
});
private static StoredRadio Read(TextBox host, TextBox port, CheckBox enabled) => new()

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}";

View File

@@ -4,6 +4,10 @@
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.IO.Ports" Version="10.0.11" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -0,0 +1,77 @@
using System.IO.Ports;
using System.Text;
namespace Nonemm.Rig;
/// An SO2R box speaking OTRSP, the protocol microHAM and others use: short
/// commands terminated by a carriage return. `TX1` sends on radio one, `RX2`
/// listens to radio two, `RX1S` puts both radios in the headphones and `AUX103`
/// sets an output line.
public sealed class OtrspBox : So2rBox
{
private readonly Stream stream;
private readonly IDisposable? port;
private readonly SemaphoreSlim writing = new(1, 1);
private string lastTransmit = "";
private string lastReceive = "";
public OtrspBox(Stream stream, IDisposable? port = null)
{
this.stream = stream;
this.port = port;
}
public static OtrspBox Open(string portName, int baudRate = 9600)
{
SerialPort port = new(portName, baudRate, Parity.None, 8, StopBits.One);
port.Open();
return new OtrspBox(port.BaseStream, port);
}
/// A box works relays, so a command that would change nothing is not sent.
/// N1MM does the same.
public Task SetTransmitAsync(int radioNumber, CancellationToken cancellation = default) =>
SendOnceAsync($"TX{Radio(radioNumber)}", ref lastTransmit, cancellation);
public Task SetReceiveAsync(
int radioNumber,
bool bothRadios = false,
CancellationToken cancellation = default) =>
SendOnceAsync($"RX{Radio(radioNumber)}{(bothRadios ? "S" : "")}", ref lastReceive, cancellation);
public Task SetAuxiliaryAsync(int radioNumber, int code, CancellationToken cancellation = default) =>
SendAsync($"AUX{Radio(radioNumber)}{Math.Clamp(code, 0, 99):00}", cancellation);
public void Dispose()
{
port?.Dispose();
writing.Dispose();
}
private Task SendOnceAsync(string command, ref string last, CancellationToken cancellation)
{
if (command == last)
{
return Task.CompletedTask;
}
last = command;
return SendAsync(command, cancellation);
}
private async Task SendAsync(string command, CancellationToken cancellation)
{
byte[] bytes = Encoding.ASCII.GetBytes(command + "\r");
await writing.WaitAsync(cancellation).ConfigureAwait(false);
try
{
await stream.WriteAsync(bytes, cancellation).ConfigureAwait(false);
await stream.FlushAsync(cancellation).ConfigureAwait(false);
}
finally
{
writing.Release();
}
}
private static int Radio(int radioNumber) => radioNumber == 2 ? 2 : 1;
}

19
src/Nonemm.Rig/So2rBox.cs Normal file
View File

@@ -0,0 +1,19 @@
namespace Nonemm.Rig;
/// The box between the operator and two radios: it decides which radio the
/// transmitter and the key reach, and which one the headphones hear. Without a
/// box the operator does that with switches, and the logger only has to keep
/// track of which radio it is on.
public interface So2rBox : IDisposable
{
/// Points the transmitter and the key at one radio.
Task SetTransmitAsync(int radioNumber, CancellationToken cancellation = default);
/// Points the headphones at one radio, or at both when `bothRadios` is set,
/// which is how an operator listens to the second radio while the first is
/// sending.
Task SetReceiveAsync(int radioNumber, bool bothRadios = false, CancellationToken cancellation = default);
/// Sets a box output, which is usually an antenna or band-decoder line.
Task SetAuxiliaryAsync(int radioNumber, int code, CancellationToken cancellation = default);
}

View File

@@ -8,11 +8,11 @@ namespace Nonemm.Session;
/// Answers what the callsign being typed could be, a column per source.
public sealed class CheckWindowSources
{
private readonly LoggingSession session;
private readonly RadioPosition session;
private readonly CallDatabase database;
private readonly Bandmap bandmap;
public CheckWindowSources(LoggingSession session, CallDatabase database, Bandmap bandmap)
public CheckWindowSources(RadioPosition session, CallDatabase database, Bandmap bandmap)
{
this.session = session;
this.database = database;

View File

@@ -0,0 +1,121 @@
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Storage;
namespace Nonemm.Session;
/// The running contest: the log, the score and what happens when a contact is
/// logged. One of these per contest, however many radios the station has.
/// What the operator is typing lives in `RadioPosition`, one per radio.
///
/// No UI framework is referenced here, so the behaviour an operator judges the
/// logger by is tested with plain unit tests.
public sealed class ContestSession
{
private readonly LogStore store;
public ContestSession(
LogStore store,
Contest contest,
ContestInstance instance,
StationInfo me,
CountryFile? countries)
{
this.store = store;
Countries = countries;
Contest = contest;
Instance = instance;
Me = me;
Log = new ContestLog(contest, me, countries);
Log.Restore(store.Qsos(instance.ContestNumber));
Editor = new QsoEditor(contest, me, countries);
SentNumber = NextSentNumber();
}
public Contest Contest { get; }
public ContestInstance Instance { get; }
public StationInfo Me { get; }
public ContestLog Log { get; }
public QsoEditor Editor { get; }
public CountryFile? Countries { get; }
/// Serial numbers count up across the station, not per radio.
public int SentNumber { get; private set; }
public string Operator { get; set; } = "";
public event EventHandler? Changed;
public event EventHandler<Qso>? Logged;
/// A contact that was edited, with the call and time it had before. The
/// other stations need the old pair to find their copy of it.
public event EventHandler<QsoChange>? Edited;
public event EventHandler<Qso>? Deleted;
/// Scores the contact, writes it and hands back what was stored: the
/// timestamp can move by a second when another contact with the same call
/// already holds it.
public Qso Add(Qso qso)
{
Qso scored = Log.Add(qso);
Qso stored = store.Add(scored);
if (stored.TimestampUtc != scored.TimestampUtc)
{
Log.Replace(stored);
}
SentNumber = NextSentNumber();
Logged?.Invoke(this, stored);
Changed?.Invoke(this, EventArgs.Empty);
return stored;
}
public void Delete(string id)
{
Qso? gone = Log.Qsos.FirstOrDefault(q => q.Id == id);
store.Delete(id);
Log.Remove(id);
if (gone is not null)
{
Deleted?.Invoke(this, gone);
}
Changed?.Invoke(this, EventArgs.Empty);
}
/// Changes one field of a logged contact. A refused edit leaves the log
/// alone and the caller gets the reason back.
public QsoEdit Edit(string id, QsoField field, string text) =>
EditWith(id, before => Editor.Apply(before, field, text));
/// Replaces a logged contact with one the caller has already built and
/// checked. The edit contact dialog changes many fields at once and comes
/// in this way.
public void Update(Qso qso) => EditWith(qso.Id, _ => QsoEdit.Accept(qso));
public void NotifyChanged() => Changed?.Invoke(this, EventArgs.Empty);
private QsoEdit EditWith(string id, Func<Qso, QsoEdit> change)
{
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)
?? throw new InvalidOperationException($"no contact with id {id} in the log");
QsoEdit edit = change(before);
if (edit.Result is not null)
{
store.Update(edit.Result);
Log.Replace(edit.Result);
Edited?.Invoke(this, new QsoChange(edit.Result, before.Call.Text, before.TimestampUtc));
Changed?.Invoke(this, EventArgs.Empty);
}
return edit;
}
private int NextSentNumber() =>
Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1;
}

View File

@@ -10,7 +10,7 @@ public static class MessageExpander
/// they are shorter.
private const string CutDigits = "T12345678N";
public static string Expand(string template, LoggingSession session)
public static string Expand(string template, RadioPosition session)
{
StringBuilder text = new();
int at = 0;
@@ -41,7 +41,7 @@ public static class MessageExpander
return text.ToString();
}
private static string Macro(string name, LoggingSession session) => name.ToUpperInvariant() switch
private static string Macro(string name, RadioPosition session) => name.ToUpperInvariant() switch
{
"MYCALL" => session.Me.Callsign,
"CALL" => session.Entry.Call.Trim(),

View File

@@ -5,46 +5,25 @@ using Nonemm.Storage;
namespace Nonemm.Session;
/// The running contest: what the operator is typing, what the log says about
/// it, and what happens when the contact is logged. It holds no reference to a
/// UI toolkit, so the behaviour an operator judges the logger by is tested with
/// plain unit tests.
public sealed class LoggingSession
/// One radio and what the operator is typing on it. A single-radio station has
/// one of these; an SO2R station has two, sharing one `ContestSession` and so
/// one log, one score and one run of serial numbers.
public sealed class RadioPosition
{
private readonly LogStore store;
private readonly CountryFile? countries;
public LoggingSession(
LogStore store,
Contest contest,
ContestInstance instance,
StationInfo me,
CountryFile? countries)
public RadioPosition(ContestSession session, int radioNumber = 1)
{
this.store = store;
this.countries = countries;
Contest = contest;
Instance = instance;
Me = me;
Log = new ContestLog(contest, me, countries);
Log.Restore(store.Qsos(instance.ContestNumber));
Entry = new EntryFields(contest.ExchangeFieldsFor(me));
Editor = new QsoEditor(contest, me, countries);
SentNumber = NextSentNumber();
Session = session;
RadioNumber = radioNumber;
Entry = new EntryFields(session.Contest.ExchangeFieldsFor(session.Me));
}
public Contest Contest { get; }
public ContestSession Session { get; }
public ContestInstance Instance { get; }
public StationInfo Me { get; }
public ContestLog Log { get; }
/// 1 or 2. Contacts record which radio made them.
public int RadioNumber { get; }
public EntryFields Entry { get; }
public QsoEditor Editor { get; }
/// With no radio connected this stays where it was last typed.
public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
@@ -53,32 +32,28 @@ public sealed class LoggingSession
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 string Operator { get; set; } = "";
/// True while the operator is calling CQ rather than searching.
/// True while the operator is calling CQ on this radio rather than
/// searching. Each radio runs or searches on its own.
public bool IsRunning { get; set; }
public event EventHandler? Changed;
public Contest Contest => Session.Contest;
public event EventHandler<Qso>? Logged;
public ContestInstance Instance => Session.Instance;
/// A contact that was edited, with the call and time it had before. The
/// other stations need the old pair to find their copy of it.
public event EventHandler<QsoChange>? Edited;
public StationInfo Me => Session.Me;
public event EventHandler<Qso>? Deleted;
public ContestLog Log => Session.Log;
public QsoEditor Editor => Session.Editor;
public int SentNumber => Session.SentNumber;
public void Tune(Frequency frequency, Mode? mode = null, Frequency? transmitFrequency = null)
{
Frequency = frequency;
Mode = mode ?? Mode;
TransmitFrequency = transmitFrequency ?? TransmitFrequency;
Changed?.Invoke(this, EventArgs.Empty);
Session.NotifyChanged();
}
/// What the log says about what is typed now. Null when there is no call to
@@ -90,7 +65,7 @@ public sealed class LoggingSession
}
public CountryLookup? Country() =>
Entry.Call.Trim().Length == 0 ? null : countries?.Find(Entry.Call.Trim());
Entry.Call.Trim().Length == 0 ? null : Session.Countries?.Find(Entry.Call.Trim());
/// Every earlier contact with the call being typed, newest first.
public IReadOnlyList<Qso> WorkedBefore() =>
@@ -107,73 +82,33 @@ public sealed class LoggingSession
{
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete");
}
Qso scored = Log.Add(BuildQso(Entry.Call.Trim()));
Qso stored = store.Add(scored);
if (stored.TimestampUtc != scored.TimestampUtc)
{
Log.Replace(stored);
}
Qso stored = Session.Add(BuildQso(Entry.Call.Trim()));
Entry.Clear();
SentNumber = NextSentNumber();
Logged?.Invoke(this, stored);
Changed?.Invoke(this, EventArgs.Empty);
return stored;
}
public void Wipe()
{
Entry.Clear();
Changed?.Invoke(this, EventArgs.Empty);
Session.NotifyChanged();
}
public void Delete(string id)
/// The report an operator sends without thinking about it: 599 on CW and
/// digital modes, 59 on phone.
public string DefaultReport() => Mode.Category switch
{
Qso? gone = Log.Qsos.FirstOrDefault(q => q.Id == id);
store.Delete(id);
Log.Remove(id);
if (gone is not null)
{
Deleted?.Invoke(this, gone);
}
Changed?.Invoke(this, EventArgs.Empty);
}
/// Changes one field of a logged contact. A refused edit leaves the log
/// alone and the caller gets the reason back.
public QsoEdit Edit(string id, QsoField field, string text) =>
EditWith(id, before => Editor.Apply(before, field, text));
/// Replaces a logged contact with one the caller has already built and
/// checked. The edit contact dialog changes many fields at once and comes
/// in this way.
public void Update(Qso qso) => EditWith(qso.Id, _ => QsoEdit.Accept(qso));
private QsoEdit EditWith(string id, Func<Qso, QsoEdit> change)
{
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)
?? throw new InvalidOperationException($"no contact with id {id} in the log");
QsoEdit edit = change(before);
if (edit.Result is not null)
{
store.Update(edit.Result);
Log.Replace(edit.Result);
Edited?.Invoke(this, new QsoChange(edit.Result, before.Call.Text, before.TimestampUtc));
Changed?.Invoke(this, EventArgs.Empty);
}
return edit;
}
private int NextSentNumber() =>
Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1;
ModeCategory.Cw => "599",
ModeCategory.Phone => "59",
_ => "599",
};
private Qso BuildQso(string call)
{
Callsign parsed = Callsign.Parse(call);
Qso qso = new()
{
Id = Qso.NewId(),
TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)),
Call = parsed,
Call = Callsign.Parse(call),
Frequency = Frequency,
QsxFrequency = TransmitFrequency,
Mode = Mode,
@@ -182,10 +117,10 @@ public sealed class LoggingSession
ContestNumber = Instance.ContestNumber,
SentReport = DefaultReport(),
SentNumber = SentNumber,
Operator = Operator.Length > 0 ? Operator : Me.Callsign,
Operator = Session.Operator.Length > 0 ? Session.Operator : Me.Callsign,
IsRunQso = IsRunning,
};
return ApplyExchange(CountryFields.Apply(qso, countries));
return ApplyExchange(CountryFields.Apply(qso, Session.Countries));
}
private Qso ApplyExchange(Qso qso)
@@ -214,14 +149,5 @@ public sealed class LoggingSession
return qso;
}
/// The report an operator sends without thinking about it: 599 on CW and
/// digital modes, 59 on phone.
public string DefaultReport() => Mode.Category switch
{
ModeCategory.Cw => "599",
ModeCategory.Phone => "59",
_ => "599",
};
private static int Number(string value) => int.TryParse(value, out int parsed) ? parsed : 0;
}