diff --git a/Nonemm.slnx b/Nonemm.slnx index 9b59dd6..d7f39d1 100644 --- a/Nonemm.slnx +++ b/Nonemm.slnx @@ -19,6 +19,7 @@ + diff --git a/README.md b/README.md index 3eb168b..cd79f38 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points. | While typing | dupe check, multiplier check, points, country and zone from the country file | | Windows | entry, log, check, bandmap, score summary, packet | | Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations | -| Radio | hamlib `rigctld`, reconnecting on its own | +| Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own | | Cluster | DX cluster over telnet, spots feeding the bandmap, Alt+P to spot a station | | Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines | | Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message | @@ -120,6 +120,36 @@ Correcting the country prefix by hand changes the score. The country file is a best guess for calls it has no rule for, so what the contact says now wins over what the file says. +### Radios + +**Config → Radios** takes a `rigctld` address per radio. Each radio needs its +own `rigctld`, started for whichever rig is on that port: + + rigctld -m 2028 -r /dev/ttyUSB0 -t 4532 + rigctld -m 1035 -r /dev/ttyUSB1 -t 4533 + +Commands go out with a `+` in front, which asks `rigctld` for its extended +answer: named fields ended by an `RPRT` line. The raw answer is bare values with +no terminator, so the client has to know how many lines each command returns, +and one wrong count leaves the connection reading every later answer against +the wrong command. + +Split is read from the radio and recorded: the contact stores where we +transmitted in N1MM's QSX column, the entry window shows `14008.00 ▸ 14020.0`, +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. + +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. + ### The bandmap A frequency scale down the left with the stations written out beside it. Each @@ -134,10 +164,9 @@ Callsigns are coloured by the same scorer as the entry window, so a dupe reads as a dupe here too. Clicking a callsign puts the radio there with the call already in the entry window; clicking anywhere else just moves the radio. -The green bar on the scale is the receiver, as wide as the mode it is in. -`VfoRole` has transmit and second-radio bars ready, but nothing feeds them yet: -the radio does not report a transmit VFO or a second radio, so those bars stay -out rather than showing a guess. +The bars on the scale are as wide as the mode passes: green where you are +listening, red where the radio transmits when working split, orange for the +other radio of a two-radio station. Band-plan colouring of the scale is not there. The segments differ by ITU region and the program has no band-plan table, so it would be guesswork. @@ -210,7 +239,9 @@ beyond logging them, and the check window's Call History and Exchange columns, which are left out rather than shown empty. The radio, network and keyer clients are tested against fakes that speak the -documented protocols. None has been run against a real radio or keyer. The +documented protocols — the radio one over a real socket, against a stand-in for +`rigctld` that answers in the extended form. None has been run against a real +radio or keyer. The cluster client is tested over a real socket against a node fake that sends the telnet negotiation, the login prompt and spot lines, but not against a live node. diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index ff24667..f03eb34 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -18,7 +18,8 @@ namespace Nonemm.App; public sealed class AppSession : IDisposable { private LogStore? store; - private RigctldRadio? radio; + private readonly List radios = []; + private int activeRadio; private ClusterClient? cluster; private StationNetwork? network; private MessageSender? keyer; @@ -52,7 +53,14 @@ public sealed class AppSession : IDisposable public CheckWindowSources? Check { get; private set; } - public Radio? Radio => radio; + /// One entry per configured radio, in radio-number order. + public IReadOnlyList Radios => radios; + + /// The radio the operator is on. Null when none is configured, and then + /// 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 ClusterClient? Cluster => cluster; @@ -107,7 +115,10 @@ 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 LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries) + { + RadioNumber = ActiveRadioNumber, + }; Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty); Logging.Logged += (_, qso) => Bandmap.Add(new Spot( qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log)); @@ -214,22 +225,74 @@ public sealed class AppSession : IDisposable Changed?.Invoke(this, EventArgs.Empty); } - public void ConnectRadio() + /// Opens a connection per enabled radio. A second one makes the station + /// SO2R: both are read, but only the one the operator is on drives the + /// entry window. + public void ConnectRadios() { - radio?.Dispose(); - radio = new RigctldRadio(Settings.RigctldHost, Settings.RigctldPort); - radio.Moved += (_, state) => Logging?.Tune(state.Frequency, state.Mode); - radio.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty); - radio.Start(); + DisposeRadios(); + int number = 1; + foreach (StoredRadio configured in Settings.Radios.Where(r => r.IsEnabled)) + { + RigctldRadio opened = new(configured.Host, configured.Port, number++); + opened.Moved += (_, state) => RadioMoved(opened, state); + opened.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty); + radios.Add(opened); + opened.Start(); + } + activeRadio = Math.Min(activeRadio, Math.Max(0, radios.Count - 1)); + Changed?.Invoke(this, EventArgs.Empty); } - public void DisconnectRadio() + public void DisconnectRadios() { - radio?.Dispose(); - radio = null; + DisposeRadios(); Changed?.Invoke(this, EventArgs.Empty); } + /// Moves the operator to the other radio. The entry window follows where + /// that radio is sitting, and contacts are logged against its number. + public void SwapRadio() + { + if (radios.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); + } + } + Changed?.Invoke(this, EventArgs.Empty); + } + + /// 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) + { + Logging?.Tune(state.Frequency, state.Mode, state.TransmitFrequency); + } + else + { + Changed?.Invoke(this, EventArgs.Empty); + } + } + + private void DisposeRadios() + { + foreach (RigctldRadio open in radios) + { + open.Dispose(); + } + radios.Clear(); + } + public void ConnectCluster() { cluster?.Dispose(); @@ -265,7 +328,7 @@ public sealed class AppSession : IDisposable public void Dispose() { - radio?.Dispose(); + DisposeRadios(); cluster?.Dispose(); network?.Dispose(); keyer?.Dispose(); diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index e3e0bdc..5bbd379 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -21,9 +21,15 @@ public sealed record Settings public IReadOnlyList ClusterCommands { get; init; } = []; - public string RigctldHost { get; init; } = "127.0.0.1"; + /// One entry per radio, in radio-number order. A second radio makes the + /// station SO2R. + public IReadOnlyList Radios { get; init; } = []; - public int RigctldPort { get; init; } = 4532; + /// Written by versions that only knew one radio. Read once, to fill + /// `Radios` in, and never written again. + public string RigctldHost { get; init; } = ""; + + public int RigctldPort { get; init; } public bool RadioEnabled { get; init; } @@ -66,7 +72,8 @@ public sealed record Settings } try { - return JsonSerializer.Deserialize(File.ReadAllText(path), Json) ?? new Settings(); + return Migrated( + JsonSerializer.Deserialize(File.ReadAllText(path), Json) ?? new Settings()); } catch (JsonException) { @@ -78,6 +85,33 @@ public sealed record Settings public void Save(string path) => File.WriteAllText(path, JsonSerializer.Serialize(this, Json)); + + /// Carries the single radio an older settings file holds into the list. + private static Settings Migrated(Settings settings) => + settings.Radios.Count > 0 || settings.RigctldHost.Length == 0 + ? settings + : settings with + { + Radios = [new StoredRadio + { + Host = settings.RigctldHost, + Port = settings.RigctldPort, + IsEnabled = settings.RadioEnabled, + }], + RigctldHost = "", + RigctldPort = 0, + RadioEnabled = false, + }; +} + +/// One radio's `rigctld`. +public sealed record StoredRadio +{ + public string Host { get; init; } = "127.0.0.1"; + + public int Port { get; init; } = 4532; + + public bool IsEnabled { get; init; } } /// The operator's station as it is stored, kept separate from `StationInfo` so diff --git a/src/Nonemm.App/Dialogs/RadioDialog.axaml b/src/Nonemm.App/Dialogs/RadioDialog.axaml index e07b037..98caf93 100644 --- a/src/Nonemm.App/Dialogs/RadioDialog.axaml +++ b/src/Nonemm.App/Dialogs/RadioDialog.axaml @@ -1,18 +1,30 @@ - - - - - + Text="The logger reads and tunes each radio through its own hamlib rigctld, started separately for whichever radio is on the desk, for example: rigctld -m 2028 -r /dev/ttyUSB0 -t 4532" /> + + + + + + + + + + + + + - +