From 1185faed78ed184adb47cad5d8e58d894c7a94dc Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 27 Aug 2026 16:49:24 +0000 Subject: [PATCH] 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 --- README.md | 33 ++-- src/Nonemm.App/AppSession.cs | 113 +++++++++++--- src/Nonemm.App/Configuration/Settings.cs | 4 + .../Dialogs/EditContactDialog.axaml.cs | 4 +- src/Nonemm.App/Dialogs/RadioDialog.axaml | 5 +- src/Nonemm.App/Dialogs/RadioDialog.axaml.cs | 2 + src/Nonemm.App/Windows/BandmapWindow.axaml.cs | 26 ++-- src/Nonemm.App/Windows/EntryWindow.Menu.cs | 11 +- src/Nonemm.App/Windows/EntryWindow.axaml | 2 +- src/Nonemm.App/Windows/EntryWindow.axaml.cs | 79 +++++++++- src/Nonemm.App/Windows/LogWindow.axaml.cs | 2 +- src/Nonemm.Rig/Nonemm.Rig.csproj | 4 + src/Nonemm.Rig/OtrspBox.cs | 77 ++++++++++ src/Nonemm.Rig/So2rBox.cs | 19 +++ src/Nonemm.Session/CheckWindowSources.cs | 4 +- src/Nonemm.Session/ContestSession.cs | 121 +++++++++++++++ src/Nonemm.Session/MessageExpander.cs | 4 +- .../{LoggingSession.cs => RadioPosition.cs} | 144 +++++------------- tests/Nonemm.Rig.Tests/OtrspBoxTests.cs | 69 +++++++++ .../ContestSessionTests.cs | 116 ++++++++++++++ .../MessageExpanderTests.cs | 8 +- ...gSessionTests.cs => RadioPositionTests.cs} | 75 ++++----- 22 files changed, 714 insertions(+), 208 deletions(-) create mode 100644 src/Nonemm.Rig/OtrspBox.cs create mode 100644 src/Nonemm.Rig/So2rBox.cs create mode 100644 src/Nonemm.Session/ContestSession.cs rename src/Nonemm.Session/{LoggingSession.cs => RadioPosition.cs} (51%) create mode 100644 tests/Nonemm.Rig.Tests/OtrspBoxTests.cs create mode 100644 tests/Nonemm.Session.Tests/ContestSessionTests.cs rename tests/Nonemm.Session.Tests/{LoggingSessionTests.cs => RadioPositionTests.cs} (79%) diff --git a/README.md b/README.md index cd79f38..6c094d9 100644 --- a/README.md +++ b/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 everything else it reported still counts. -A second radio makes the station SO2R. Both are read and both show on the -bandmap — the one you are on in green, the other in orange — but only the one -you are on drives the entry window, so the second radio moving cannot drag you -off the station you are working. Ctrl+Tab moves you to the other radio; contacts -record which one made them. +A second radio makes the station SO2R and opens a second entry window. The two +windows share one log, one score and one run of serial numbers, so a station +worked on radio 1 is a dupe on radio 2. What is typed, the frequency, the mode +and whether you are running belong to each radio on its own. -Not there yet: a second entry window, alternating CQ, and audio switching. What -is here is two radios read and logged correctly, not a full two-radio operating -position. +| Key | What it does | +|---|---| +| 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 @@ -211,11 +224,11 @@ itself keys a contact on. | `Nonemm.Contests` | Contest rules, the scoring engine, `.udc` files | | `Nonemm.Formats` | Cabrillo out, ADIF in and out | | `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.Network` | Contacts shared between the stations of a multi-operator entry | | `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 | The split at `Nonemm.Session` is the important one: it references no UI diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index f03eb34..a550a8d 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -19,10 +19,12 @@ public sealed class AppSession : IDisposable { private LogStore? store; private readonly List radios = []; + private readonly List 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 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? 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(); } diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 5bbd379..60fb9ed 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -43,6 +43,10 @@ public sealed record Settings public IReadOnlyList 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"; diff --git a/src/Nonemm.App/Dialogs/EditContactDialog.axaml.cs b/src/Nonemm.App/Dialogs/EditContactDialog.axaml.cs index 1747531..3c848af 100644 --- a/src/Nonemm.App/Dialogs/EditContactDialog.axaml.cs +++ b/src/Nonemm.App/Dialogs/EditContactDialog.axaml.cs @@ -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 boxes = []; private int at; - public EditContactDialog(LoggingSession logging, string contactId) + public EditContactDialog(ContestSession logging, string contactId) { this.logging = logging; InitializeComponent(); diff --git a/src/Nonemm.App/Dialogs/RadioDialog.axaml b/src/Nonemm.App/Dialogs/RadioDialog.axaml index 98caf93..242bb9d 100644 --- a/src/Nonemm.App/Dialogs/RadioDialog.axaml +++ b/src/Nonemm.App/Dialogs/RadioDialog.axaml @@ -23,8 +23,11 @@ + + + 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." />