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:
33
README.md
33
README.md
@@ -140,15 +140,28 @@ and the bandmap draws a red bar at the transmit frequency. A radio that cannot
|
|||||||
do split answers `RPRT -11`; that is an answer, not a broken connection, and
|
do split answers `RPRT -11`; that is an answer, not a broken connection, and
|
||||||
everything else it reported still counts.
|
everything else it reported still counts.
|
||||||
|
|
||||||
A second radio makes the station SO2R. Both are read and both show on the
|
A second radio makes the station SO2R and opens a second entry window. The two
|
||||||
bandmap — the one you are on in green, the other in orange — but only the one
|
windows share one log, one score and one run of serial numbers, so a station
|
||||||
you are on drives the entry window, so the second radio moving cannot drag you
|
worked on radio 1 is a dupe on radio 2. What is typed, the frequency, the mode
|
||||||
off the station you are working. Ctrl+Tab moves you to the other radio; contacts
|
and whether you are running belong to each radio on its own.
|
||||||
record which one made them.
|
|
||||||
|
|
||||||
Not there yet: a second entry window, alternating CQ, and audio switching. What
|
| Key | What it does |
|
||||||
is here is two radios read and logged correctly, not a full two-radio operating
|
|---|---|
|
||||||
position.
|
| Ctrl+Tab | moves you to the other radio: the keyboard, the entry window and the box all follow |
|
||||||
|
| Ctrl+Shift+Tab | puts both radios in the headphones, and back |
|
||||||
|
|
||||||
|
Both radios show on the bandmap — the one you are on in green, the other in
|
||||||
|
orange — but only the one you are on drives its entry window, so the second
|
||||||
|
radio moving cannot drag you off the station you are working.
|
||||||
|
|
||||||
|
An SO2R box speaking OTRSP routes the key and the headphones. Name its serial
|
||||||
|
port in **Config → Radios** and it follows: `TX2` when you move to radio 2,
|
||||||
|
`RX1S` when you ask for both radios, and `TX` again before every message goes
|
||||||
|
out, so a message cannot go out of the radio you have just left. A command that
|
||||||
|
would change nothing is not sent, because the box works relays. Without a box
|
||||||
|
the logger just keeps track of which radio it is on and you switch by hand.
|
||||||
|
|
||||||
|
Not there yet: alternating CQ, and voice keying on the second radio.
|
||||||
|
|
||||||
### The bandmap
|
### The bandmap
|
||||||
|
|
||||||
@@ -211,11 +224,11 @@ itself keys a contact on.
|
|||||||
| `Nonemm.Contests` | Contest rules, the scoring engine, `.udc` files |
|
| `Nonemm.Contests` | Contest rules, the scoring engine, `.udc` files |
|
||||||
| `Nonemm.Formats` | Cabrillo out, ADIF in and out |
|
| `Nonemm.Formats` | Cabrillo out, ADIF in and out |
|
||||||
| `Nonemm.Storage` | The N1MM-compatible `.s3db` |
|
| `Nonemm.Storage` | The N1MM-compatible `.s3db` |
|
||||||
| `Nonemm.Rig` | Radio control over `rigctld` |
|
| `Nonemm.Rig` | Radio control over `rigctld`, and the SO2R box |
|
||||||
| `Nonemm.Spotting` | Spots, the bandmap, the DX cluster client |
|
| `Nonemm.Spotting` | Spots, the bandmap, the DX cluster client |
|
||||||
| `Nonemm.Network` | Contacts shared between the stations of a multi-operator entry |
|
| `Nonemm.Network` | Contacts shared between the stations of a multi-operator entry |
|
||||||
| `Nonemm.Keying` | CW through `cwdaemon` or a WinKeyer |
|
| `Nonemm.Keying` | CW through `cwdaemon` or a WinKeyer |
|
||||||
| `Nonemm.Session` | What the operator is typing and what the log says about it — no UI toolkit |
|
| `Nonemm.Session` | The contest in progress, and one entry position per radio — no UI toolkit |
|
||||||
| `Nonemm.App` | The Avalonia windows |
|
| `Nonemm.App` | The Avalonia windows |
|
||||||
|
|
||||||
The split at `Nonemm.Session` is the important one: it references no UI
|
The split at `Nonemm.Session` is the important one: it references no UI
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ public sealed class AppSession : IDisposable
|
|||||||
{
|
{
|
||||||
private LogStore? store;
|
private LogStore? store;
|
||||||
private readonly List<RigctldRadio> radios = [];
|
private readonly List<RigctldRadio> radios = [];
|
||||||
|
private readonly List<RadioPosition> positions = [];
|
||||||
private int activeRadio;
|
private int activeRadio;
|
||||||
private ClusterClient? cluster;
|
private ClusterClient? cluster;
|
||||||
private StationNetwork? network;
|
private StationNetwork? network;
|
||||||
private MessageSender? keyer;
|
private MessageSender? keyer;
|
||||||
|
private So2rBox? box;
|
||||||
|
|
||||||
public AppSession(UserPaths paths, Settings settings)
|
public AppSession(UserPaths paths, Settings settings)
|
||||||
{
|
{
|
||||||
@@ -49,7 +51,16 @@ public sealed class AppSession : IDisposable
|
|||||||
|
|
||||||
public Bandmap Bandmap { get; } = new();
|
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; }
|
public CheckWindowSources? Check { get; private set; }
|
||||||
|
|
||||||
@@ -60,7 +71,7 @@ public sealed class AppSession : IDisposable
|
|||||||
/// frequency and mode stay wherever they were last typed.
|
/// frequency and mode stay wherever they were last typed.
|
||||||
public Radio? Radio => activeRadio < radios.Count ? radios[activeRadio] : null;
|
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;
|
public ClusterClient? Cluster => cluster;
|
||||||
|
|
||||||
@@ -68,6 +79,13 @@ public sealed class AppSession : IDisposable
|
|||||||
|
|
||||||
public MessageSender? Keyer => keyer;
|
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? Changed;
|
||||||
|
|
||||||
public event EventHandler? ContestChanged;
|
public event EventHandler? ContestChanged;
|
||||||
@@ -115,10 +133,13 @@ public sealed class AppSession : IDisposable
|
|||||||
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
|
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
|
||||||
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
|
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
|
||||||
SaveDefinition(contest);
|
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.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||||
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
|
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
|
||||||
qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
|
qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
|
||||||
@@ -126,11 +147,14 @@ public sealed class AppSession : IDisposable
|
|||||||
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
|
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
|
||||||
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
|
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
|
||||||
Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign);
|
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 });
|
Save(Settings with { ContestNumber = contestNumber });
|
||||||
ContestChanged?.Invoke(this, EventArgs.Empty);
|
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
|
/// Starts, restarts or stops the link to the other stations of a
|
||||||
/// multi-operator entry, following what the settings now say.
|
/// multi-operator entry, following what the settings now say.
|
||||||
public void ApplyNetworkSettings()
|
public void ApplyNetworkSettings()
|
||||||
@@ -240,7 +264,12 @@ public sealed class AppSession : IDisposable
|
|||||||
radios.Add(opened);
|
radios.Add(opened);
|
||||||
opened.Start();
|
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);
|
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.
|
/// that radio is sitting, and contacts are logged against its number.
|
||||||
public void SwapRadio()
|
public void SwapRadio()
|
||||||
{
|
{
|
||||||
if (radios.Count < 2)
|
if (positions.Count < 2)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activeRadio = (activeRadio + 1) % radios.Count;
|
activeRadio = (activeRadio + 1) % positions.Count;
|
||||||
if (Logging is not null)
|
_ = FollowActiveRadioAsync();
|
||||||
{
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
Logging.RadioNumber = ActiveRadioNumber;
|
ActiveRadioChanged?.Invoke(this, ActiveRadioNumber);
|
||||||
if (Radio?.State is { } state)
|
|
||||||
{
|
|
||||||
Logging.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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);
|
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,
|
/// 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.
|
/// but it must not drag the entry window off the contact being worked.
|
||||||
private void RadioMoved(Radio moved, RadioState state)
|
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);
|
return;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
}
|
||||||
|
position.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DisposeRadios()
|
private void DisposeRadios()
|
||||||
@@ -332,6 +398,7 @@ public sealed class AppSession : IDisposable
|
|||||||
cluster?.Dispose();
|
cluster?.Dispose();
|
||||||
network?.Dispose();
|
network?.Dispose();
|
||||||
keyer?.Dispose();
|
keyer?.Dispose();
|
||||||
|
box?.Dispose();
|
||||||
store?.Dispose();
|
store?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ public sealed record Settings
|
|||||||
|
|
||||||
public IReadOnlyList<string> NetworkPeers { get; init; } = [];
|
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`.
|
/// `none`, `cwdaemon` or `winkeyer`.
|
||||||
public string KeyerKind { get; init; } = "none";
|
public string KeyerKind { get; init; } = "none";
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ public sealed partial class EditContactDialog : Window
|
|||||||
QsoField.CountryPrefix, QsoField.WpxPrefix,
|
QsoField.CountryPrefix, QsoField.WpxPrefix,
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly LoggingSession logging;
|
private readonly ContestSession logging;
|
||||||
private readonly Dictionary<QsoField, TextBox> boxes = [];
|
private readonly Dictionary<QsoField, TextBox> boxes = [];
|
||||||
private int at;
|
private int at;
|
||||||
|
|
||||||
public EditContactDialog(LoggingSession logging, string contactId)
|
public EditContactDialog(ContestSession logging, string contactId)
|
||||||
{
|
{
|
||||||
this.logging = logging;
|
this.logging = logging;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|||||||
@@ -23,8 +23,11 @@
|
|||||||
<CheckBox Grid.Row="4" Grid.Column="1" Name="SecondEnabledBox" Content="Follow this radio"
|
<CheckBox Grid.Row="4" Grid.Column="1" Name="SecondEnabledBox" Content="Follow this radio"
|
||||||
Margin="0,2,0,0" />
|
Margin="0,2,0,0" />
|
||||||
</Grid>
|
</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"
|
<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">
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
||||||
<Button Content="Cancel" Click="OnCancel" />
|
<Button Content="Cancel" Click="OnCancel" />
|
||||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed partial class RadioDialog : Window
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
Show(0, HostBox, PortBox, EnabledBox);
|
Show(0, HostBox, PortBox, EnabledBox);
|
||||||
Show(1, SecondHostBox, SecondPortBox, SecondEnabledBox);
|
Show(1, SecondHostBox, SecondPortBox, SecondEnabledBox);
|
||||||
|
BoxPortBox.Text = settings.So2rBoxPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Show(int at, TextBox host, TextBox port, CheckBox enabled)
|
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(HostBox, PortBox, EnabledBox),
|
||||||
Read(SecondHostBox, SecondPortBox, SecondEnabledBox),
|
Read(SecondHostBox, SecondPortBox, SecondEnabledBox),
|
||||||
],
|
],
|
||||||
|
So2rBoxPort = (BoxPortBox.Text ?? "").Trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
private static StoredRadio Read(TextBox host, TextBox port, CheckBox enabled) => new()
|
private static StoredRadio Read(TextBox host, TextBox port, CheckBox enabled) => new()
|
||||||
|
|||||||
@@ -43,18 +43,18 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
|||||||
public override void Refresh()
|
public override void Refresh()
|
||||||
{
|
{
|
||||||
session.Bandmap.DropOlderThan(DateTime.UtcNow);
|
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";
|
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.Spots = [];
|
||||||
View.Vfos = [];
|
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();
|
View.InvalidateVisual();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(Frequency low, Frequency high) = Window(band, session.Logging.Frequency);
|
(Frequency low, Frequency high) = Window(band, session.Position.Frequency);
|
||||||
View.Low = low;
|
View.Low = low;
|
||||||
View.High = high;
|
View.High = high;
|
||||||
View.TickStep = StepFor(high.Hertz - low.Hertz);
|
View.TickStep = StepFor(high.Hertz - low.Hertz);
|
||||||
@@ -69,19 +69,19 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
|||||||
/// station.
|
/// station.
|
||||||
private IReadOnlyList<VfoMarker> Vfos()
|
private IReadOnlyList<VfoMarker> Vfos()
|
||||||
{
|
{
|
||||||
if (session.Logging is null)
|
if (session.Position is null)
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
Frequency width = Modes.Bandwidth(session.Logging.Mode);
|
Frequency width = Modes.Bandwidth(session.Position.Mode);
|
||||||
List<VfoMarker> markers =
|
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(
|
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))
|
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)
|
private Verdict? VerdictFor(Spot spot)
|
||||||
{
|
{
|
||||||
if (session.Logging is null)
|
if (session.Position is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return session.Logging.Log.Judge(new Qso
|
return session.Position.Log.Judge(new Qso
|
||||||
{
|
{
|
||||||
Id = "",
|
Id = "",
|
||||||
TimestampUtc = DateTime.UtcNow,
|
TimestampUtc = DateTime.UtcNow,
|
||||||
Call = spot.Call,
|
Call = spot.Call,
|
||||||
Frequency = spot.Frequency,
|
Frequency = spot.Frequency,
|
||||||
Mode = session.Logging.Mode,
|
Mode = session.Position.Mode,
|
||||||
ContestName = session.Logging.Contest.Name,
|
ContestName = session.Position.Contest.Name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ public sealed partial class EntryWindow
|
|||||||
Status("there is nothing in the log yet");
|
Status("there is nothing in the log yet");
|
||||||
return;
|
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
|
/// 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.Save(updated);
|
||||||
session.ConnectRadios();
|
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
|
Status(session.Radios.Count switch
|
||||||
{
|
{
|
||||||
0 => "no radio",
|
0 => "no radio",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</Window.Styles>
|
</Window.Styles>
|
||||||
|
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<Menu DockPanel.Dock="Top">
|
<Menu Name="MainMenu" DockPanel.Dock="Top">
|
||||||
<MenuItem Header="_File">
|
<MenuItem Header="_File">
|
||||||
<MenuItem Header="_New Database…" Click="OnNewDatabase" />
|
<MenuItem Header="_New Database…" Click="OnNewDatabase" />
|
||||||
<MenuItem Header="_Open Database…" Click="OnOpenDatabase" />
|
<MenuItem Header="_Open Database…" Click="OnOpenDatabase" />
|
||||||
|
|||||||
@@ -15,30 +15,54 @@ namespace Nonemm.App.Windows;
|
|||||||
public sealed partial class EntryWindow : Window
|
public sealed partial class EntryWindow : Window
|
||||||
{
|
{
|
||||||
private readonly AppSession session;
|
private readonly AppSession session;
|
||||||
|
private readonly int radioNumber;
|
||||||
|
private EntryWindow? secondRadio;
|
||||||
private readonly List<TextBox> boxes = [];
|
private readonly List<TextBox> boxes = [];
|
||||||
private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) };
|
private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) };
|
||||||
private readonly Dictionary<Type, Window> openWindows = [];
|
private readonly Dictionary<Type, Window> openWindows = [];
|
||||||
private bool updating;
|
private bool updating;
|
||||||
|
|
||||||
public EntryWindow(AppSession session)
|
public EntryWindow(AppSession session, int radioNumber = 1)
|
||||||
{
|
{
|
||||||
this.session = session;
|
this.session = session;
|
||||||
|
this.radioNumber = radioNumber;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
BuildFunctionKeys();
|
BuildFunctionKeys();
|
||||||
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
||||||
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(BuildEntryBoxes);
|
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
BuildEntryBoxes();
|
||||||
|
ShowSecondRadio();
|
||||||
|
});
|
||||||
clock.Tick += (_, _) => UpdateClock();
|
clock.Tick += (_, _) => UpdateClock();
|
||||||
clock.Start();
|
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();
|
Opened += (_, _) => Reopen();
|
||||||
|
if (radioNumber > 1)
|
||||||
|
{
|
||||||
|
Title = "Nonemm — radio 2";
|
||||||
|
MainMenu.IsVisible = false;
|
||||||
|
}
|
||||||
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
|
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.
|
/// Reopens the database and contest the operator was last in.
|
||||||
private void Reopen()
|
private void Reopen()
|
||||||
{
|
{
|
||||||
|
if (radioNumber > 1)
|
||||||
|
{
|
||||||
|
BuildEntryBoxes();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
int contestNumber = session.Settings.ContestNumber;
|
int contestNumber = session.Settings.ContestNumber;
|
||||||
@@ -57,6 +81,42 @@ public sealed partial class EntryWindow : Window
|
|||||||
}
|
}
|
||||||
StartConnections();
|
StartConnections();
|
||||||
BuildEntryBoxes();
|
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
|
/// 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);
|
yield return ("keyer", session.ApplyKeyerSettings);
|
||||||
}
|
}
|
||||||
|
if (session.Settings.So2rBoxPort.Length > 0)
|
||||||
|
{
|
||||||
|
yield return ("SO2R box", session.ApplySo2rSettings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BuildEntryBoxes()
|
private void BuildEntryBoxes()
|
||||||
@@ -175,10 +239,15 @@ public sealed partial class EntryWindow : Window
|
|||||||
SyncBoxes();
|
SyncBoxes();
|
||||||
boxes[0].Focus();
|
boxes[0].Focus();
|
||||||
break;
|
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):
|
case Key.Tab when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
session.SwapRadio();
|
session.SwapRadio();
|
||||||
Status($"radio {session.ActiveRadioNumber}");
|
|
||||||
break;
|
break;
|
||||||
case Key.Tab when FocusedIsEntryBox():
|
case Key.Tab when FocusedIsEntryBox():
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
@@ -392,6 +461,8 @@ public sealed partial class EntryWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
string text = MessageExpander.Expand(template, Logging);
|
string text = MessageExpander.Expand(template, Logging);
|
||||||
|
// the box has to point at this radio before the key does
|
||||||
|
_ = session.PointTransmitAtAsync(radioNumber);
|
||||||
Status($"sending {text}");
|
Status($"sending {text}");
|
||||||
_ = SendAsync(text);
|
_ = SendAsync(text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
message = "";
|
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.Qsos} contacts · {logging.Log.Tally.Points} points · " +
|
||||||
$"{logging.Log.Tally.TotalMultipliers} multipliers · score {logging.Log.TotalScore:N0}";
|
$"{logging.Log.Tally.TotalMultipliers} multipliers · score {logging.Log.TotalScore:N0}";
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
|
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.IO.Ports" Version="10.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|||||||
77
src/Nonemm.Rig/OtrspBox.cs
Normal file
77
src/Nonemm.Rig/OtrspBox.cs
Normal 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
19
src/Nonemm.Rig/So2rBox.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -8,11 +8,11 @@ namespace Nonemm.Session;
|
|||||||
/// Answers what the callsign being typed could be, a column per source.
|
/// Answers what the callsign being typed could be, a column per source.
|
||||||
public sealed class CheckWindowSources
|
public sealed class CheckWindowSources
|
||||||
{
|
{
|
||||||
private readonly LoggingSession session;
|
private readonly RadioPosition session;
|
||||||
private readonly CallDatabase database;
|
private readonly CallDatabase database;
|
||||||
private readonly Bandmap bandmap;
|
private readonly Bandmap bandmap;
|
||||||
|
|
||||||
public CheckWindowSources(LoggingSession session, CallDatabase database, Bandmap bandmap)
|
public CheckWindowSources(RadioPosition session, CallDatabase database, Bandmap bandmap)
|
||||||
{
|
{
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this.database = database;
|
this.database = database;
|
||||||
|
|||||||
121
src/Nonemm.Session/ContestSession.cs
Normal file
121
src/Nonemm.Session/ContestSession.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ public static class MessageExpander
|
|||||||
/// they are shorter.
|
/// they are shorter.
|
||||||
private const string CutDigits = "T12345678N";
|
private const string CutDigits = "T12345678N";
|
||||||
|
|
||||||
public static string Expand(string template, LoggingSession session)
|
public static string Expand(string template, RadioPosition session)
|
||||||
{
|
{
|
||||||
StringBuilder text = new();
|
StringBuilder text = new();
|
||||||
int at = 0;
|
int at = 0;
|
||||||
@@ -41,7 +41,7 @@ public static class MessageExpander
|
|||||||
return text.ToString();
|
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,
|
"MYCALL" => session.Me.Callsign,
|
||||||
"CALL" => session.Entry.Call.Trim(),
|
"CALL" => session.Entry.Call.Trim(),
|
||||||
|
|||||||
@@ -5,46 +5,25 @@ using Nonemm.Storage;
|
|||||||
|
|
||||||
namespace Nonemm.Session;
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
/// The running contest: what the operator is typing, what the log says about
|
/// One radio and what the operator is typing on it. A single-radio station has
|
||||||
/// it, and what happens when the contact is logged. It holds no reference to a
|
/// one of these; an SO2R station has two, sharing one `ContestSession` and so
|
||||||
/// UI toolkit, so the behaviour an operator judges the logger by is tested with
|
/// one log, one score and one run of serial numbers.
|
||||||
/// plain unit tests.
|
public sealed class RadioPosition
|
||||||
public sealed class LoggingSession
|
|
||||||
{
|
{
|
||||||
private readonly LogStore store;
|
public RadioPosition(ContestSession session, int radioNumber = 1)
|
||||||
private readonly CountryFile? countries;
|
|
||||||
|
|
||||||
public LoggingSession(
|
|
||||||
LogStore store,
|
|
||||||
Contest contest,
|
|
||||||
ContestInstance instance,
|
|
||||||
StationInfo me,
|
|
||||||
CountryFile? countries)
|
|
||||||
{
|
{
|
||||||
this.store = store;
|
Session = session;
|
||||||
this.countries = countries;
|
RadioNumber = radioNumber;
|
||||||
Contest = contest;
|
Entry = new EntryFields(session.Contest.ExchangeFieldsFor(session.Me));
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Contest Contest { get; }
|
public ContestSession Session { get; }
|
||||||
|
|
||||||
public ContestInstance Instance { get; }
|
/// 1 or 2. Contacts record which radio made them.
|
||||||
|
public int RadioNumber { get; }
|
||||||
public StationInfo Me { get; }
|
|
||||||
|
|
||||||
public ContestLog Log { get; }
|
|
||||||
|
|
||||||
public EntryFields Entry { get; }
|
public EntryFields Entry { get; }
|
||||||
|
|
||||||
public QsoEditor Editor { get; }
|
|
||||||
|
|
||||||
/// With no radio connected this stays where it was last typed.
|
/// With no radio connected this stays where it was last typed.
|
||||||
public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
|
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;
|
public Mode Mode { get; private set; } = Modes.Cw;
|
||||||
|
|
||||||
/// Which radio of a two-radio station the operator is on.
|
/// True while the operator is calling CQ on this radio rather than
|
||||||
public int RadioNumber { get; set; } = 1;
|
/// searching. Each radio runs or searches on its own.
|
||||||
|
|
||||||
public int SentNumber { get; private set; }
|
|
||||||
|
|
||||||
public string Operator { get; set; } = "";
|
|
||||||
|
|
||||||
/// True while the operator is calling CQ rather than searching.
|
|
||||||
public bool IsRunning { get; set; }
|
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
|
public StationInfo Me => Session.Me;
|
||||||
/// other stations need the old pair to find their copy of it.
|
|
||||||
public event EventHandler<QsoChange>? Edited;
|
|
||||||
|
|
||||||
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)
|
public void Tune(Frequency frequency, Mode? mode = null, Frequency? transmitFrequency = null)
|
||||||
{
|
{
|
||||||
Frequency = frequency;
|
Frequency = frequency;
|
||||||
Mode = mode ?? Mode;
|
Mode = mode ?? Mode;
|
||||||
TransmitFrequency = transmitFrequency ?? TransmitFrequency;
|
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
|
/// 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() =>
|
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.
|
/// Every earlier contact with the call being typed, newest first.
|
||||||
public IReadOnlyList<Qso> WorkedBefore() =>
|
public IReadOnlyList<Qso> WorkedBefore() =>
|
||||||
@@ -107,73 +82,33 @@ public sealed class LoggingSession
|
|||||||
{
|
{
|
||||||
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete");
|
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete");
|
||||||
}
|
}
|
||||||
Qso scored = Log.Add(BuildQso(Entry.Call.Trim()));
|
Qso stored = Session.Add(BuildQso(Entry.Call.Trim()));
|
||||||
Qso stored = store.Add(scored);
|
|
||||||
if (stored.TimestampUtc != scored.TimestampUtc)
|
|
||||||
{
|
|
||||||
Log.Replace(stored);
|
|
||||||
}
|
|
||||||
Entry.Clear();
|
Entry.Clear();
|
||||||
SentNumber = NextSentNumber();
|
|
||||||
Logged?.Invoke(this, stored);
|
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Wipe()
|
public void Wipe()
|
||||||
{
|
{
|
||||||
Entry.Clear();
|
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);
|
ModeCategory.Cw => "599",
|
||||||
store.Delete(id);
|
ModeCategory.Phone => "59",
|
||||||
Log.Remove(id);
|
_ => "599",
|
||||||
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;
|
|
||||||
|
|
||||||
private Qso BuildQso(string call)
|
private Qso BuildQso(string call)
|
||||||
{
|
{
|
||||||
Callsign parsed = Callsign.Parse(call);
|
|
||||||
Qso qso = new()
|
Qso qso = new()
|
||||||
{
|
{
|
||||||
Id = Qso.NewId(),
|
Id = Qso.NewId(),
|
||||||
TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)),
|
TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)),
|
||||||
Call = parsed,
|
Call = Callsign.Parse(call),
|
||||||
Frequency = Frequency,
|
Frequency = Frequency,
|
||||||
QsxFrequency = TransmitFrequency,
|
QsxFrequency = TransmitFrequency,
|
||||||
Mode = Mode,
|
Mode = Mode,
|
||||||
@@ -182,10 +117,10 @@ public sealed class LoggingSession
|
|||||||
ContestNumber = Instance.ContestNumber,
|
ContestNumber = Instance.ContestNumber,
|
||||||
SentReport = DefaultReport(),
|
SentReport = DefaultReport(),
|
||||||
SentNumber = SentNumber,
|
SentNumber = SentNumber,
|
||||||
Operator = Operator.Length > 0 ? Operator : Me.Callsign,
|
Operator = Session.Operator.Length > 0 ? Session.Operator : Me.Callsign,
|
||||||
IsRunQso = IsRunning,
|
IsRunQso = IsRunning,
|
||||||
};
|
};
|
||||||
return ApplyExchange(CountryFields.Apply(qso, countries));
|
return ApplyExchange(CountryFields.Apply(qso, Session.Countries));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Qso ApplyExchange(Qso qso)
|
private Qso ApplyExchange(Qso qso)
|
||||||
@@ -214,14 +149,5 @@ public sealed class LoggingSession
|
|||||||
return qso;
|
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;
|
private static int Number(string value) => int.TryParse(value, out int parsed) ? parsed : 0;
|
||||||
}
|
}
|
||||||
69
tests/Nonemm.Rig.Tests/OtrspBoxTests.cs
Normal file
69
tests/Nonemm.Rig.Tests/OtrspBoxTests.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Nonemm.Rig.Tests;
|
||||||
|
|
||||||
|
public class OtrspBoxTests
|
||||||
|
{
|
||||||
|
private static string Written(MemoryStream stream) =>
|
||||||
|
Encoding.ASCII.GetString(stream.ToArray());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PointingTheTransmitterNamesTheRadio()
|
||||||
|
{
|
||||||
|
MemoryStream sent = new();
|
||||||
|
using OtrspBox box = new(sent);
|
||||||
|
|
||||||
|
await box.SetTransmitAsync(2);
|
||||||
|
|
||||||
|
Assert.Equal("TX2\r", Written(sent));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListeningToBothRadiosAddsTheStereoFlag()
|
||||||
|
{
|
||||||
|
MemoryStream sent = new();
|
||||||
|
using OtrspBox box = new(sent);
|
||||||
|
|
||||||
|
await box.SetReceiveAsync(1, bothRadios: true);
|
||||||
|
|
||||||
|
Assert.Equal("RX1S\r", Written(sent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A box works relays. Saying the same thing again would rattle them for
|
||||||
|
/// nothing, so it is not said.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheSameCommandIsNotSentTwice()
|
||||||
|
{
|
||||||
|
MemoryStream sent = new();
|
||||||
|
using OtrspBox box = new(sent);
|
||||||
|
|
||||||
|
await box.SetTransmitAsync(1);
|
||||||
|
await box.SetTransmitAsync(1);
|
||||||
|
await box.SetTransmitAsync(2);
|
||||||
|
|
||||||
|
Assert.Equal("TX1\rTX2\r", Written(sent));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TransmitAndReceiveAreTrackedApart()
|
||||||
|
{
|
||||||
|
MemoryStream sent = new();
|
||||||
|
using OtrspBox box = new(sent);
|
||||||
|
|
||||||
|
await box.SetTransmitAsync(1);
|
||||||
|
await box.SetReceiveAsync(1);
|
||||||
|
|
||||||
|
Assert.Equal("TX1\rRX1\r", Written(sent));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnAuxiliaryCodeIsSentAsTwoDigits()
|
||||||
|
{
|
||||||
|
MemoryStream sent = new();
|
||||||
|
using OtrspBox box = new(sent);
|
||||||
|
|
||||||
|
await box.SetAuxiliaryAsync(2, 3);
|
||||||
|
|
||||||
|
Assert.Equal("AUX203\r", Written(sent));
|
||||||
|
}
|
||||||
|
}
|
||||||
116
tests/Nonemm.Session.Tests/ContestSessionTests.cs
Normal file
116
tests/Nonemm.Session.Tests/ContestSessionTests.cs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
using Nonemm.Contests;
|
||||||
|
using Nonemm.Contests.Rules;
|
||||||
|
using Nonemm.Core;
|
||||||
|
using Nonemm.Core.Country;
|
||||||
|
using Nonemm.Storage;
|
||||||
|
|
||||||
|
namespace Nonemm.Session.Tests;
|
||||||
|
|
||||||
|
/// Two radios sharing one contest.
|
||||||
|
public class ContestSessionTests
|
||||||
|
{
|
||||||
|
private static readonly StationInfo Me = new()
|
||||||
|
{
|
||||||
|
Callsign = "DL1ABC",
|
||||||
|
CqZone = 14,
|
||||||
|
Continent = "EU",
|
||||||
|
CountryPrefix = "DL",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ContestSession Session()
|
||||||
|
{
|
||||||
|
FakeLogStore store = new();
|
||||||
|
ContestInstance instance = store.AddContest(new ContestInstance
|
||||||
|
{
|
||||||
|
ContestNumber = 0,
|
||||||
|
ContestName = "CQWPXCW",
|
||||||
|
});
|
||||||
|
return new ContestSession(store, new CqWpx(ModeCategory.Cw), instance, Me, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Type(RadioPosition radio, string call, string number)
|
||||||
|
{
|
||||||
|
radio.Entry.Call = call;
|
||||||
|
radio.Entry.Set(ExchangeSlot.ReceivedReport, "599");
|
||||||
|
radio.Entry.Set(ExchangeSlot.SerialNumber, number);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BothRadiosLogIntoTheSameLog()
|
||||||
|
{
|
||||||
|
ContestSession session = Session();
|
||||||
|
RadioPosition first = new(session, 1);
|
||||||
|
RadioPosition second = new(session, 2);
|
||||||
|
|
||||||
|
Type(first, "JA1XYZ", "1");
|
||||||
|
first.LogContact();
|
||||||
|
Type(second, "W3LPL", "2");
|
||||||
|
second.LogContact();
|
||||||
|
|
||||||
|
Assert.Equal(2, session.Log.Qsos.Count);
|
||||||
|
Assert.Equal([1, 2], session.Log.Qsos.Select(q => q.RadioNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serial numbers belong to the station, not to a radio, so the second
|
||||||
|
/// radio carries on where the first left off.
|
||||||
|
[Fact]
|
||||||
|
public void SerialNumbersCountUpAcrossBothRadios()
|
||||||
|
{
|
||||||
|
ContestSession session = Session();
|
||||||
|
RadioPosition first = new(session, 1);
|
||||||
|
RadioPosition second = new(session, 2);
|
||||||
|
|
||||||
|
Type(first, "JA1XYZ", "1");
|
||||||
|
Assert.Equal(1, first.LogContact().SentNumber);
|
||||||
|
|
||||||
|
Type(second, "W3LPL", "2");
|
||||||
|
Assert.Equal(2, second.LogContact().SentNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A station worked on radio 1 is a dupe on radio 2, or the second radio
|
||||||
|
/// spends the contest working stations again.
|
||||||
|
[Fact]
|
||||||
|
public void AStationWorkedOnOneRadioIsADupeOnTheOther()
|
||||||
|
{
|
||||||
|
ContestSession session = Session();
|
||||||
|
RadioPosition first = new(session, 1);
|
||||||
|
RadioPosition second = new(session, 2);
|
||||||
|
|
||||||
|
Type(first, "JA1XYZ", "1");
|
||||||
|
first.LogContact();
|
||||||
|
|
||||||
|
Type(second, "JA1XYZ", "2");
|
||||||
|
Assert.True(second.Verdict()?.IsDupe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two radios are on different bands and are running or searching
|
||||||
|
/// independently, so neither of those can be shared.
|
||||||
|
[Fact]
|
||||||
|
public void EachRadioKeepsItsOwnFrequencyAndRunState()
|
||||||
|
{
|
||||||
|
ContestSession session = Session();
|
||||||
|
RadioPosition first = new(session, 1);
|
||||||
|
RadioPosition second = new(session, 2);
|
||||||
|
|
||||||
|
first.Tune(Frequency.FromKilohertz(14_025));
|
||||||
|
first.IsRunning = true;
|
||||||
|
second.Tune(Frequency.FromKilohertz(7_025));
|
||||||
|
|
||||||
|
Assert.Equal(14_025_000, first.Frequency.Hertz);
|
||||||
|
Assert.Equal(7_025_000, second.Frequency.Hertz);
|
||||||
|
Assert.True(first.IsRunning);
|
||||||
|
Assert.False(second.IsRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WhatIsTypedOnOneRadioStaysThere()
|
||||||
|
{
|
||||||
|
ContestSession session = Session();
|
||||||
|
RadioPosition first = new(session, 1);
|
||||||
|
RadioPosition second = new(session, 2);
|
||||||
|
|
||||||
|
Type(first, "JA1XYZ", "1");
|
||||||
|
|
||||||
|
Assert.Equal("", second.Entry.Call);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ namespace Nonemm.Session.Tests;
|
|||||||
|
|
||||||
public class MessageExpanderTests
|
public class MessageExpanderTests
|
||||||
{
|
{
|
||||||
private static LoggingSession Session()
|
private static RadioPosition Session()
|
||||||
{
|
{
|
||||||
FakeLogStore store = new();
|
FakeLogStore store = new();
|
||||||
ContestInstance instance = store.AddContest(new ContestInstance
|
ContestInstance instance = store.AddContest(new ContestInstance
|
||||||
@@ -16,18 +16,18 @@ public class MessageExpanderTests
|
|||||||
ContestName = "CQWW",
|
ContestName = "CQWW",
|
||||||
SentExchange = "14",
|
SentExchange = "14",
|
||||||
});
|
});
|
||||||
return new LoggingSession(
|
return new RadioPosition(new ContestSession(
|
||||||
store,
|
store,
|
||||||
new CqWorldWide(ModeCategory.Cw),
|
new CqWorldWide(ModeCategory.Cw),
|
||||||
instance,
|
instance,
|
||||||
new StationInfo { Callsign = "DL1ABC", CqZone = 14, Name = "Erik" },
|
new StationInfo { Callsign = "DL1ABC", CqZone = 14, Name = "Erik" },
|
||||||
null);
|
null));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MyCallAndTheirCallAreFilledIn()
|
public void MyCallAndTheirCallAreFilledIn()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
session.Entry.Call = "JA1XYZ";
|
session.Entry.Call = "JA1XYZ";
|
||||||
Assert.Equal("JA1XYZ DE DL1ABC", MessageExpander.Expand("{CALL} DE {MYCALL}", session));
|
Assert.Equal("JA1XYZ DE DL1ABC", MessageExpander.Expand("{CALL} DE {MYCALL}", session));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Nonemm.Storage;
|
|||||||
|
|
||||||
namespace Nonemm.Session.Tests;
|
namespace Nonemm.Session.Tests;
|
||||||
|
|
||||||
public class LoggingSessionTests
|
public class RadioPositionTests
|
||||||
{
|
{
|
||||||
private const string Countries = """
|
private const string Countries = """
|
||||||
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
|
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
|
||||||
@@ -24,15 +24,17 @@ public class LoggingSessionTests
|
|||||||
CountryPrefix = "DL",
|
CountryPrefix = "DL",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static LoggingSession Session(Contest? contest = null)
|
private static RadioPosition Session(Contest? contest = null) =>
|
||||||
|
new(Shared(new FakeLogStore(), contest));
|
||||||
|
|
||||||
|
private static ContestSession Shared(FakeLogStore store, Contest? contest = null)
|
||||||
{
|
{
|
||||||
FakeLogStore store = new();
|
|
||||||
ContestInstance instance = store.AddContest(new ContestInstance
|
ContestInstance instance = store.AddContest(new ContestInstance
|
||||||
{
|
{
|
||||||
ContestNumber = 0,
|
ContestNumber = 0,
|
||||||
ContestName = "CQWW",
|
ContestName = "CQWW",
|
||||||
});
|
});
|
||||||
return new LoggingSession(
|
return new ContestSession(
|
||||||
store,
|
store,
|
||||||
contest ?? new CqWorldWide(ModeCategory.Cw),
|
contest ?? new CqWorldWide(ModeCategory.Cw),
|
||||||
instance,
|
instance,
|
||||||
@@ -40,7 +42,7 @@ public class LoggingSessionTests
|
|||||||
CountryFile.Parse(Countries));
|
CountryFile.Parse(Countries));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Type(LoggingSession session, string call, string zone)
|
private static void Type(RadioPosition session, string call, string zone)
|
||||||
{
|
{
|
||||||
session.Entry.Call = call;
|
session.Entry.Call = call;
|
||||||
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
|
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
|
||||||
@@ -50,7 +52,7 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void LoggingAContactPutsItInTheLogAndClearsTheEntry()
|
public void LoggingAContactPutsItInTheLogAndClearsTheEntry()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
Type(session, "JA1XYZ", "25");
|
Type(session, "JA1XYZ", "25");
|
||||||
Qso logged = session.LogContact();
|
Qso logged = session.LogContact();
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void CountryAndPrefixAreFilledInFromTheCountryFile()
|
public void CountryAndPrefixAreFilledInFromTheCountryFile()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
Type(session, "JA1XYZ", "25");
|
Type(session, "JA1XYZ", "25");
|
||||||
Qso logged = session.LogContact();
|
Qso logged = session.LogContact();
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void AnIncompleteExchangeIsNotLogged()
|
public void AnIncompleteExchangeIsNotLogged()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
session.Entry.Call = "JA1XYZ";
|
session.Entry.Call = "JA1XYZ";
|
||||||
Assert.Throws<InvalidOperationException>(() => session.LogContact());
|
Assert.Throws<InvalidOperationException>(() => session.LogContact());
|
||||||
}
|
}
|
||||||
@@ -84,7 +86,7 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void TheSameStationOnTheSameBandReadsAsADupe()
|
public void TheSameStationOnTheSameBandReadsAsADupe()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
Type(session, "JA1XYZ", "25");
|
Type(session, "JA1XYZ", "25");
|
||||||
session.LogContact();
|
session.LogContact();
|
||||||
Type(session, "JA1XYZ", "25");
|
Type(session, "JA1XYZ", "25");
|
||||||
@@ -94,7 +96,7 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void MovingBandClearsTheDupe()
|
public void MovingBandClearsTheDupe()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
Type(session, "JA1XYZ", "25");
|
Type(session, "JA1XYZ", "25");
|
||||||
session.LogContact();
|
session.LogContact();
|
||||||
session.Tune(Frequency.FromKilohertz(7_025));
|
session.Tune(Frequency.FromKilohertz(7_025));
|
||||||
@@ -105,7 +107,7 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void SerialNumbersCountUp()
|
public void SerialNumbersCountUp()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session(new CqWpx(ModeCategory.Cw));
|
RadioPosition session = Session(new CqWpx(ModeCategory.Cw));
|
||||||
Assert.Equal(1, session.SentNumber);
|
Assert.Equal(1, session.SentNumber);
|
||||||
session.Entry.Call = "JA1XYZ";
|
session.Entry.Call = "JA1XYZ";
|
||||||
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
|
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
|
||||||
@@ -117,7 +119,7 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void SpaceMovesThroughTheBoxesAndBackToTheCall()
|
public void SpaceMovesThroughTheBoxesAndBackToTheCall()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
Assert.Equal(0, session.Entry.Focus);
|
Assert.Equal(0, session.Entry.Focus);
|
||||||
session.Entry.Advance();
|
session.Entry.Advance();
|
||||||
Assert.Equal(1, session.Entry.Focus);
|
Assert.Equal(1, session.Entry.Focus);
|
||||||
@@ -132,7 +134,7 @@ public class LoggingSessionTests
|
|||||||
[InlineData("7025.5", 7_025_500)]
|
[InlineData("7025.5", 7_025_500)]
|
||||||
public void AFrequencyTypedIntoTheCallBoxIsRecognised(string typed, long hertz)
|
public void AFrequencyTypedIntoTheCallBoxIsRecognised(string typed, long hertz)
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
session.Entry.Call = typed;
|
session.Entry.Call = typed;
|
||||||
Assert.Equal(hertz, session.PendingQsy()?.Hertz);
|
Assert.Equal(hertz, session.PendingQsy()?.Hertz);
|
||||||
}
|
}
|
||||||
@@ -142,7 +144,7 @@ public class LoggingSessionTests
|
|||||||
[InlineData("12000")]
|
[InlineData("12000")]
|
||||||
public void ACallOrAnOutOfBandNumberIsNotAQsy(string typed)
|
public void ACallOrAnOutOfBandNumberIsNotAQsy(string typed)
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
session.Entry.Call = typed;
|
session.Entry.Call = typed;
|
||||||
Assert.Null(session.PendingQsy());
|
Assert.Null(session.PendingQsy());
|
||||||
}
|
}
|
||||||
@@ -150,14 +152,14 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void AContactDeletedFromTheLogGivesItsMultiplierBack()
|
public void AContactDeletedFromTheLogGivesItsMultiplierBack()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
Type(session, "JA1XYZ", "25");
|
Type(session, "JA1XYZ", "25");
|
||||||
Qso first = session.LogContact();
|
Qso first = session.LogContact();
|
||||||
Type(session, "JA2XYZ", "25");
|
Type(session, "JA2XYZ", "25");
|
||||||
session.LogContact();
|
session.LogContact();
|
||||||
Assert.Equal(2, session.Log.Tally.TotalMultipliers);
|
Assert.Equal(2, session.Log.Tally.TotalMultipliers);
|
||||||
|
|
||||||
session.Delete(first.Id);
|
session.Session.Delete(first.Id);
|
||||||
Assert.Single(session.Log.Qsos);
|
Assert.Single(session.Log.Qsos);
|
||||||
Assert.Equal(2, session.Log.Tally.TotalMultipliers);
|
Assert.Equal(2, session.Log.Tally.TotalMultipliers);
|
||||||
Assert.True(session.Log.Qsos.Single().IsMultiplier1);
|
Assert.True(session.Log.Qsos.Single().IsMultiplier1);
|
||||||
@@ -166,7 +168,7 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void AContactWorkedSplitRecordsWhereWeTransmitted()
|
public void AContactWorkedSplitRecordsWhereWeTransmitted()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
session.Tune(
|
session.Tune(
|
||||||
Frequency.FromKilohertz(14_025),
|
Frequency.FromKilohertz(14_025),
|
||||||
Modes.Cw,
|
Modes.Cw,
|
||||||
@@ -182,8 +184,7 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void AContactRecordsWhichRadioMadeIt()
|
public void AContactRecordsWhichRadioMadeIt()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = new(Shared(new FakeLogStore()), 2);
|
||||||
session.RadioNumber = 2;
|
|
||||||
Type(session, "JA1XYZ", "25");
|
Type(session, "JA1XYZ", "25");
|
||||||
|
|
||||||
Assert.Equal(2, session.LogContact().RadioNumber);
|
Assert.Equal(2, session.LogContact().RadioNumber);
|
||||||
@@ -194,7 +195,7 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void TuningWithoutASplitLeavesTheOneAlreadySet()
|
public void TuningWithoutASplitLeavesTheOneAlreadySet()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
session.Tune(Frequency.FromKilohertz(14_025), null, Frequency.FromKilohertz(14_200));
|
session.Tune(Frequency.FromKilohertz(14_025), null, Frequency.FromKilohertz(14_200));
|
||||||
|
|
||||||
session.Tune(Frequency.FromKilohertz(14_030));
|
session.Tune(Frequency.FromKilohertz(14_030));
|
||||||
@@ -205,12 +206,12 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void EditingAContactRescoresTheLog()
|
public void EditingAContactRescoresTheLog()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
Type(session, "JA1XYZ", "25");
|
Type(session, "JA1XYZ", "25");
|
||||||
Qso logged = session.LogContact();
|
Qso logged = session.LogContact();
|
||||||
Assert.Equal(3, session.Log.Tally.Points);
|
Assert.Equal(3, session.Log.Tally.Points);
|
||||||
|
|
||||||
QsoEdit edit = session.Edit(logged.Id, QsoField.Call, "DL2XYZ");
|
QsoEdit edit = session.Session.Edit(logged.Id, QsoField.Call, "DL2XYZ");
|
||||||
|
|
||||||
Assert.True(edit.IsAccepted);
|
Assert.True(edit.IsAccepted);
|
||||||
Assert.Equal("DL2XYZ", session.Log.Qsos.Single().Call.Text);
|
Assert.Equal("DL2XYZ", session.Log.Qsos.Single().Call.Text);
|
||||||
@@ -220,11 +221,11 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ARefusedEditLeavesTheLogAlone()
|
public void ARefusedEditLeavesTheLogAlone()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
Type(session, "JA1XYZ", "25");
|
Type(session, "JA1XYZ", "25");
|
||||||
Qso logged = session.LogContact();
|
Qso logged = session.LogContact();
|
||||||
|
|
||||||
QsoEdit edit = session.Edit(logged.Id, QsoField.Zone, "41");
|
QsoEdit edit = session.Session.Edit(logged.Id, QsoField.Zone, "41");
|
||||||
|
|
||||||
Assert.False(edit.IsAccepted);
|
Assert.False(edit.IsAccepted);
|
||||||
Assert.Equal(25, session.Log.Qsos.Single().Zone);
|
Assert.Equal(25, session.Log.Qsos.Single().Zone);
|
||||||
@@ -235,13 +236,13 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void AnEditReportsWhatTheContactUsedToBe()
|
public void AnEditReportsWhatTheContactUsedToBe()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
Type(session, "JA1XYZ", "25");
|
Type(session, "JA1XYZ", "25");
|
||||||
Qso logged = session.LogContact();
|
Qso logged = session.LogContact();
|
||||||
QsoChange? change = null;
|
QsoChange? change = null;
|
||||||
session.Edited += (_, c) => change = c;
|
session.Session.Edited += (_, c) => change = c;
|
||||||
|
|
||||||
session.Edit(logged.Id, QsoField.Call, "JA2XYZ");
|
session.Session.Edit(logged.Id, QsoField.Call, "JA2XYZ");
|
||||||
|
|
||||||
Assert.Equal("JA1XYZ", change?.OldCall);
|
Assert.Equal("JA1XYZ", change?.OldCall);
|
||||||
Assert.Equal(logged.TimestampUtc, change?.OldTimestampUtc);
|
Assert.Equal(logged.TimestampUtc, change?.OldTimestampUtc);
|
||||||
@@ -253,12 +254,12 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void CorrectingTheCountryChangesTheScore()
|
public void CorrectingTheCountryChangesTheScore()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
Type(session, "JA1XYZ", "25");
|
Type(session, "JA1XYZ", "25");
|
||||||
Qso logged = session.LogContact();
|
Qso logged = session.LogContact();
|
||||||
Assert.Equal(3, session.Log.Tally.Points);
|
Assert.Equal(3, session.Log.Tally.Points);
|
||||||
|
|
||||||
session.Edit(logged.Id, QsoField.CountryPrefix, "DL");
|
session.Session.Edit(logged.Id, QsoField.CountryPrefix, "DL");
|
||||||
|
|
||||||
Assert.Equal(0, session.Log.Tally.Points);
|
Assert.Equal(0, session.Log.Tally.Points);
|
||||||
}
|
}
|
||||||
@@ -266,13 +267,13 @@ public class LoggingSessionTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ADeleteReportsTheContactThatWent()
|
public void ADeleteReportsTheContactThatWent()
|
||||||
{
|
{
|
||||||
LoggingSession session = Session();
|
RadioPosition session = Session();
|
||||||
Type(session, "JA1XYZ", "25");
|
Type(session, "JA1XYZ", "25");
|
||||||
Qso logged = session.LogContact();
|
Qso logged = session.LogContact();
|
||||||
Qso? gone = null;
|
Qso? gone = null;
|
||||||
session.Deleted += (_, q) => gone = q;
|
session.Session.Deleted += (_, q) => gone = q;
|
||||||
|
|
||||||
session.Delete(logged.Id);
|
session.Session.Delete(logged.Id);
|
||||||
|
|
||||||
Assert.Equal("JA1XYZ", gone?.Call.Text);
|
Assert.Equal("JA1XYZ", gone?.Call.Text);
|
||||||
}
|
}
|
||||||
@@ -281,12 +282,16 @@ public class LoggingSessionTests
|
|||||||
public void StoredContactsAreReadBackWhenTheSessionStarts()
|
public void StoredContactsAreReadBackWhenTheSessionStarts()
|
||||||
{
|
{
|
||||||
FakeLogStore store = new();
|
FakeLogStore store = new();
|
||||||
ContestInstance instance = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWW" });
|
RadioPosition first = new(Shared(store));
|
||||||
LoggingSession first = new(store, new CqWorldWide(ModeCategory.Cw), instance, Me, CountryFile.Parse(Countries));
|
|
||||||
Type(first, "JA1XYZ", "25");
|
Type(first, "JA1XYZ", "25");
|
||||||
first.LogContact();
|
first.LogContact();
|
||||||
|
|
||||||
LoggingSession second = new(store, new CqWorldWide(ModeCategory.Cw), instance, Me, CountryFile.Parse(Countries));
|
RadioPosition second = new(new ContestSession(
|
||||||
|
store,
|
||||||
|
new CqWorldWide(ModeCategory.Cw),
|
||||||
|
first.Instance,
|
||||||
|
Me,
|
||||||
|
CountryFile.Parse(Countries)));
|
||||||
Assert.Single(second.Log.Qsos);
|
Assert.Single(second.Log.Qsos);
|
||||||
Assert.Equal(3, second.Log.Tally.Points);
|
Assert.Equal(3, second.Log.Tally.Points);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user