using Nonemm.Keying; namespace Nonemm.Session; /// Calls CQ on one radio, and when that message has gone out, moves to the /// other radio and calls there. N1MM calls it dueling CQs and toggles it with /// Ctrl+B. /// /// Only the CQ message carries the alternation on. A station answers, the /// operator sends their call and the exchange, and those messages leave the /// alternation where it is until the next CQ goes out. N1MM works the same /// way: it holds on any other message and on Escape, and carries on once the /// CQ message is sent again, which is what happens after the contact is /// logged. /// /// It runs off the keyer's completion signal. Working out when a message ends /// from the length of the text would be a guess, and a guess that runs short /// keys the second radio while the first is still sending. /// /// There is a keyer per mode — the CW keyer and the voice keyer — and a CQ /// goes out through whichever the radio's mode calls for, so it listens to all /// of them. /// /// Two radios, because that is what an SO2R station has. public sealed class AlternatingCq : IDisposable { private readonly IReadOnlyList keyers; private readonly Func callCqOn; private readonly Func wait; private readonly Lock gate = new(); private CancellationTokenSource? running; private bool carriesOn = true; /// `callCqOn` moves the operator to that radio and sends the CQ message. /// `wait` is there so a test does not have to sleep. public AlternatingCq( IReadOnlyList keyers, Func callCqOn, TimeSpan gap, Func? wait = null) { this.keyers = keyers; this.callCqOn = callCqOn; Gap = gap; this.wait = wait ?? Task.Delay; foreach (MessageSender keyer in keyers) { keyer.Finished += OnFinished; } } /// How long to leave between the message ending and the other radio /// starting. The relays of an SO2R box need a moment. public TimeSpan Gap { get; } public bool IsRunning { get { lock (gate) { return running is not null; } } } /// The radio it is calling on now. public int RadioNumber { get; private set; } /// True while a message finishing moves the CQ to the other radio, false /// while the operator is working a station. public bool CarriesOn { get { lock (gate) { return carriesOn; } } } /// Says the CQ message has gone out, so the next message that finishes /// moves the CQ to the other radio. public void CarryOn() => Set(true); /// Says something else has gone out, or that the operator has stopped the /// keyer, so the CQ stays where it is until the next one is sent. public void Hold() => Set(false); private void Set(bool value) { lock (gate) { carriesOn = value; } } /// Raised when it stops by itself, with the reason. Stopping by hand does /// not raise it. public event EventHandler? Stopped; /// True when a keyer that can drive this at all is connected. public bool IsPossible => keyers.Any(keyer => keyer.ReportsCompletion); public void Start(int radioNumber) { if (!IsPossible) { throw new InvalidOperationException("this keyer does not report when a message has gone out"); } CancellationToken token; lock (gate) { running?.Cancel(); running?.Dispose(); running = new CancellationTokenSource(); token = running.Token; } RadioNumber = radioNumber; Set(true); _ = CallAsync(radioNumber, token); } public void Stop() { lock (gate) { running?.Cancel(); running?.Dispose(); running = null; } } public void Dispose() { foreach (MessageSender keyer in keyers) { keyer.Finished -= OnFinished; } Stop(); } private void OnFinished(object? sender, EventArgs e) { CancellationToken token; lock (gate) { if (running is null || !carriesOn) { return; } token = running.Token; } _ = NextAsync(token); } private async Task NextAsync(CancellationToken token) { try { await wait(Gap, token).ConfigureAwait(false); } catch (OperationCanceledException) { return; } if (token.IsCancellationRequested) { return; } RadioNumber = RadioNumber == 1 ? 2 : 1; await CallAsync(RadioNumber, token).ConfigureAwait(false); } /// A keyer that has gone away stops the whole thing rather than leaving one /// radio calling into a dead port. private async Task CallAsync(int radioNumber, CancellationToken token) { try { await callCqOn(radioNumber).ConfigureAwait(false); } catch (Exception e) when (e is InvalidOperationException or IOException) { // read before Stop, which cancels this same token bool stoppedByHand = token.IsCancellationRequested; Stop(); if (!stoppedByHand) { Stopped?.Invoke(this, e.Message); } } } }