diff --git a/README.md b/README.md index 1911e7c..de9e20e 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,27 @@ 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. +Not there yet: voice keying on the second radio. + +### Alternating CQ + +**Ctrl+B** calls CQ on one radio, and when that message has gone out, moves to +the other radio and calls there, until Escape or another Ctrl+B. The keyboard, +the entry window and the SO2R box follow each turn. N1MM calls this dueling CQs +and puts it on the same key. + +It runs off the keyer saying the message has gone out, not off a guess at how +long the text takes: `cwdaemon` answers the `h` reply request, and a +WinKeyer clears the busy bit in its status byte. A keyer that cannot report this +cannot drive alternating CQ, and says so rather than keying the second radio +while the first is still sending. **Config → Keyer and messages** sets the gap +between the two, which is 100 ms by default because an SO2R box works relays. + +CW only for now: it sends the F1 message, and there is no voice keyer. + +Why the keying is `cwdaemon`'s job rather than the logger's, and what it would +take to key the port directly as N1MM does, is in +[`docs/keying.md`](docs/keying.md). ### The bandmap diff --git a/docs/keying.md b/docs/keying.md new file mode 100644 index 0000000..a2b8d90 --- /dev/null +++ b/docs/keying.md @@ -0,0 +1,87 @@ +# How CW gets on the air, and why + +Written 2026-08-27. + +Nonemm has two keying paths and neither of them times the Morse itself: + +| Path | What it is | +|---|---| +| `CwDaemonSender` | UDP to `cwdaemon`, which keys DTR or RTS on a serial port and does the element timing | +| `WinkeyerSender` | serial to a WinKeyer in host mode, which does the element timing in hardware | + +Keying the serial port ourselves — writing the dots and dashes from inside the +logger — was looked at and left out. This file says what that would take and +why the answer was no for now. + +## What N1MM does + +N1MM has no cwdaemon. It keys the port itself, in `CWInt.cs`: + +- `PortOn(n)` sets `CommPort.DtrEnable` or `RtsEnable` (or writes a parallel + port bit through `inpout32.dll`), then waits `1200000 × n ÷ wpm` + microseconds, minus the time the port write itself took. `PortOff(n)` is the + same with the line dropped. `n` is the element length in dot units. +- The wait is `waitunit`: sleep in 40, 20, 4 and 1 ms steps while there is + slack, then busy-wait on a `Stopwatch` for the rest. An element can end late + but never early. +- The spin margin measures the machine. `CntDnAmount` starts at 2000 µs and + grows every time a sleep overshoots — 200 µs for a small overshoot, up to + 10 ms for a large one — and never shrinks during the run. After a few + characters it has found how sloppy this machine's timers are and starts + spinning early enough to land on time. +- Error does not accumulate: each `PortOn`/`PortOff` starts its own + `Stopwatch`, so one late element does not push the rest late. +- `sendCW` raises the thread to `THREAD_PRIORITY_TIME_CRITICAL` for the length + of the message — `SetPriority((IntPtr)32, 15)` — and drops it back to normal + afterwards. While CW is going out, that thread preempts the screen, the + database and the network. + +The keying also sits behind its own UDP listener, `CWIFMain` and `UDPClass`, +which N1MM calls the CW interface. In N1MM Classic it was a separate process; +in Logger+ it is a module in the same process, still spoken to over UDP. That +is the same shape as cwdaemon. + +## Why we use cwdaemon instead + +Most of N1MM's recipe ports. `SerialPort.DtrEnable` works on Linux, `Stopwatch` +is the same class, and a thread can spin the same way. Two things do not: + +**Thread priority.** `THREAD_PRIORITY_TIME_CRITICAL` has real teeth on Windows. +On Linux, .NET's `ThreadPriority.Highest` is a nice value, and nice does not +stop the scheduler taking the core away mid-element. The equivalent is +`SCHED_FIFO`, which needs `CAP_SYS_NICE` or root. cwdaemon can have that +privilege; a logger the operator starts from a desktop should not ask for it. + +**Garbage collection.** A collection that stops the keying thread part way +through an element makes an element the wrong length, and that is audible. +N1MM has the same exposure and lives with it. cwdaemon does not have it at all, +being C. + +The parallel port is not worth copying either: `inpout32` has no Linux +equivalent that works without root, and the hardware is gone. + +## What was decided + +Keep cwdaemon as the Linux path and the WinKeyer as the hardware path. A +`SerialCwSender` doing N1MM's coarse-sleep-then-spin is a reasonable third +keyer kind later: it would remove the install-cwdaemon step, it is the only +software path that works on Windows without a WinKeyer, and its completion +signal would be exact rather than a UDP round trip. On Windows it would be as +good as N1MM. On Linux it would be worse than cwdaemon, for the two reasons +above, and that is the trade to make knowingly rather than by accident. + +## Knowing when a message has gone out + +Alternating CQ needs the end of a message, and both paths report it: + +- cwdaemon: `h` in front of the message asks for a reply, and the + daemon sends `h` back on the same socket once it has played. The + request covers one message, so it goes out before every message. +- WinKeyer: a byte from 0xC0 to 0xDF is a status byte and 0x04 is set while the + keyer is sending, so busy going off is the end. The bit meanings are from + N1MM's `Winkey.cs`; the K1EL datasheet is a scanned PDF that does not extract + as text. + +Timing the message from the length of its text was rejected. The guess runs +short exactly when the operator has turned the speed up, and a short guess keys +the second radio while the first is still sending. diff --git a/docs/unfinished.md b/docs/unfinished.md index 05612b7..ebc5931 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -16,22 +16,22 @@ is still sitting there undiscovered. | `OtrspBox` | a `MemoryStream`, checking the bytes | no real SO2R box. Command forms are from N1MM's `N1MMPort.cs`. | | `ClusterClient` | a node fake over a real socket, sending the telnet negotiation, the login prompt and spot lines | no live cluster node. Which nodes send bare CR, and which send option negotiation, is guessed from N1MM's code. | | `StationNetwork` | the message format, round-tripped | no second station, and no N1MM on the same network. | -| `CwDaemonSender` | the UDP messages | no `cwdaemon`, no radio keyed. | -| `WinkeyerSender` | nothing | no test at all, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet. | +| `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `h` reply request | no `cwdaemon`, no radio keyed. | +| `WinkeyerSender` | the status-byte reader, on its own | no test of the serial side, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet; the status bits are from N1MM's `Winkey.cs`. | The Cabrillo output has not been put in front of a contest sponsor's robot. ## Half-built -**Alternating CQ (SO2R).** Needs to know when the keyer has finished sending. -`MessageSender` is fire-and-forget: it has `SendAsync`, `AbortAsync` and -`SetSpeedAsync` and no completion signal. Both `cwdaemon` and a WinKeyer can -report completion, so the interface has to grow first. Timing it from the length -of the text would be a guess that goes wrong exactly when the contest is busy. - **Voice keying.** `MessageSender` was written to cover a voice keyer playing a recording, and nothing implements it. No DVK support either, so the second radio -of an SO2R station cannot call CQ by voice. +of an SO2R station cannot call CQ by voice. Alternating CQ therefore works on CW +only, though nothing in it is CW-specific: a voice keyer that reports when the +recording has finished would drive it as it stands. + +**Alternating CQ does not restart itself after a contact.** Escape stops it, and +it has to be started again with Ctrl+B. N1MM carries on calling after the QSO is +logged. **Call history: the section-validating directives.** `!!Validate50State!!`, `!!ValidateArrlSection!!`, `!!MapOnSection!!` and `!!GTA2GH_NT2TER!!` are read diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 7872761..77a3a7a 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -24,6 +24,7 @@ public sealed class AppSession : IDisposable private ClusterClient? cluster; private StationNetwork? network; private MessageSender? keyer; + private AlternatingCq? alternating; private So2rBox? box; public AppSession(UserPaths paths, Settings settings) @@ -91,6 +92,10 @@ public sealed class AppSession : IDisposable public MessageSender? Keyer => keyer; + /// Alternating CQ, or null while there is no keyer. It needs two radios to + /// do anything, and a keyer that reports when a message has gone out. + public AlternatingCq? Alternating => alternating; + /// The SO2R box, or null when there is none and the operator switches the /// transmitter and the headphones by hand. public So2rBox? Box => box; @@ -245,6 +250,8 @@ public sealed class AppSession : IDisposable /// without one. public void ApplyKeyerSettings() { + alternating?.Dispose(); + alternating = null; keyer?.Dispose(); keyer = null; switch (Settings.KeyerKind.ToLowerInvariant()) @@ -261,6 +268,10 @@ public sealed class AppSession : IDisposable if (keyer is not null) { _ = keyer.SetSpeedAsync(Settings.KeyerSpeed); + alternating = new AlternatingCq( + keyer, + CallCqOnAsync, + TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs)); } Changed?.Invoke(this, EventArgs.Empty); } @@ -303,12 +314,47 @@ public sealed class AppSession : IDisposable { return; } - activeRadio = (activeRadio + 1) % positions.Count; + MoveToRadio(positions[(activeRadio + 1) % positions.Count].RadioNumber); + } + + /// Moves the operator to a radio by number. Alternating CQ calls this from + /// the keyer's thread, so everything it raises is posted by the windows. + public void MoveToRadio(int radioNumber) + { + int at = positions.FindIndex(p => p.RadioNumber == radioNumber); + if (at < 0 || at == activeRadio) + { + return; + } + activeRadio = at; _ = FollowActiveRadioAsync(); Changed?.Invoke(this, EventArgs.Empty); ActiveRadioChanged?.Invoke(this, ActiveRadioNumber); } + /// Moves to the radio and sends its CQ message, which is F1. Used by + /// alternating CQ; the operator's own F1 goes through the entry window. + private async Task CallCqOnAsync(int radioNumber) + { + RadioPosition position = positions.FirstOrDefault(p => p.RadioNumber == radioNumber) + ?? throw new InvalidOperationException($"there is no radio {radioNumber}"); + if (keyer is null) + { + throw new InvalidOperationException("there is no keyer"); + } + string template = Messages.For( + position.Mode.Category, + Settings.CwMessages, + Settings.PhoneMessages)[0]; + if (template.Length == 0) + { + throw new InvalidOperationException("F1 has no message — Config ▸ Keyer and messages"); + } + MoveToRadio(radioNumber); + await PointTransmitAtAsync(radioNumber).ConfigureAwait(false); + await keyer.SendAsync(MessageExpander.Expand(template, position)).ConfigureAwait(false); + } + /// 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() @@ -414,6 +460,7 @@ public sealed class AppSession : IDisposable DisposeRadios(); cluster?.Dispose(); network?.Dispose(); + alternating?.Dispose(); keyer?.Dispose(); box?.Dispose(); store?.Dispose(); diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index b567133..c69d3bb 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -62,6 +62,11 @@ public sealed record Settings public int KeyerSpeed { get; init; } = 28; + /// How long alternating CQ leaves between one message ending and the other + /// radio starting. N1MM asks for the same number and will not go below + /// 100 ms, because an SO2R box works relays. + public int AlternatingCqGapMs { get; init; } = 100; + /// Sub-band boundaries the operator has changed. Empty means the defaults /// in `BandPlan.Default`; an entry replaces one band's boundaries. public IReadOnlyList SubBands { get; init; } = []; diff --git a/src/Nonemm.App/Dialogs/KeyerDialog.axaml b/src/Nonemm.App/Dialogs/KeyerDialog.axaml index 370b5dc..8c882cc 100644 --- a/src/Nonemm.App/Dialogs/KeyerDialog.axaml +++ b/src/Nonemm.App/Dialogs/KeyerDialog.axaml @@ -15,6 +15,13 @@ + + + + + diff --git a/src/Nonemm.App/Dialogs/KeyerDialog.axaml.cs b/src/Nonemm.App/Dialogs/KeyerDialog.axaml.cs index b4fc169..83d3780 100644 --- a/src/Nonemm.App/Dialogs/KeyerDialog.axaml.cs +++ b/src/Nonemm.App/Dialogs/KeyerDialog.axaml.cs @@ -22,6 +22,7 @@ public sealed partial class KeyerDialog : Window KindBox.SelectedItem = settings.KeyerKind; KindBox.SelectionChanged += (_, _) => ShowTarget(); SpeedBox.Text = settings.KeyerSpeed.ToString(); + AlternatingGapBox.Text = settings.AlternatingCqGapMs.ToString(); CwButton.IsCheckedChanged += (_, _) => ShowMessages(); ShowTarget(); BuildMessageBoxes(); @@ -91,6 +92,10 @@ public sealed partial class KeyerDialog : Window ? port : settings.KeyerPort, KeyerSpeed = int.TryParse(SpeedBox.Text, out int speed) ? speed : settings.KeyerSpeed, + // N1MM will not go below 100 ms either: the box works relays + AlternatingCqGapMs = int.TryParse(AlternatingGapBox.Text, out int gap) + ? Math.Max(100, gap) + : settings.AlternatingCqGapMs, CwMessages = cwMessages, PhoneMessages = phoneMessages, }); diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index 39056e1..71fa740 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -234,6 +234,7 @@ public sealed partial class EntryWindow : Window break; case Key.Escape: e.Handled = true; + session.Alternating?.Stop(); _ = session.Keyer?.AbortAsync(); Logging.Wipe(); SyncBoxes(); @@ -253,6 +254,10 @@ public sealed partial class EntryWindow : Window e.Handled = true; MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift)); break; + case Key.B when e.KeyModifiers.HasFlag(KeyModifiers.Control): + e.Handled = true; + ToggleAlternatingCq(); + break; case Key.Y when e.KeyModifiers.HasFlag(KeyModifiers.Control): e.Handled = true; OnEditLastContact(this, new RoutedEventArgs()); @@ -451,6 +456,46 @@ public sealed partial class EntryWindow : Window } } + /// Alternating CQ: CQ on this radio, then the other as each message ends. + /// N1MM calls it dueling CQs and puts it on the same key. + private void ToggleAlternatingCq() + { + if (session.Alternating is not { } alternating) + { + Status("no keyer — Config ▸ Keyer"); + return; + } + if (alternating.IsRunning) + { + alternating.Stop(); + Status("alternating CQ off"); + return; + } + if (session.Positions.Count < 2) + { + Status("alternating CQ needs two radios"); + return; + } + if (!alternating.IsPossible) + { + Status("this keyer does not report when a message has gone out"); + return; + } + alternating.Stopped -= OnAlternatingStopped; + alternating.Stopped += OnAlternatingStopped; + alternating.Start(radioNumber); + Status($"alternating CQ · radio {radioNumber} first"); + } + + private void OnAlternatingStopped(object? sender, string reason) + { + if (sender is AlternatingCq alternating) + { + alternating.Stopped -= OnAlternatingStopped; + } + Dispatcher.UIThread.Post(() => Status($"alternating CQ stopped: {reason}")); + } + private void SendMessage(int index) { if (Logging is null || session.Keyer is null) diff --git a/src/Nonemm.Keying/CwDaemonSender.cs b/src/Nonemm.Keying/CwDaemonSender.cs index 85b7dc5..4d24642 100644 --- a/src/Nonemm.Keying/CwDaemonSender.cs +++ b/src/Nonemm.Keying/CwDaemonSender.cs @@ -7,21 +7,40 @@ namespace Nonemm.Keying; /// Sends CW through `cwdaemon`, which keys the radio from a serial or parallel /// port and is what Linux stations usually run. Its protocol is one UDP /// datagram per command; escape sequences start with a 0x1B byte. +/// +/// `h` asks for a reply once the next message has been played, and +/// cwdaemon answers with `h` and that text. The request covers one message +/// only, so it goes out in front of every message. +/// +/// Why the timing is cwdaemon's job and not ours: `docs/keying.md`. public sealed class CwDaemonSender : MessageSender { - private readonly UdpClient socket = new(); + private const string ReplyToken = "nonemm"; + + // bound before anything is sent, so the reply has somewhere to arrive + private readonly UdpClient socket = new(new IPEndPoint(IPAddress.Any, 0)); private readonly IPEndPoint daemon; + private readonly CancellationTokenSource reading = new(); public CwDaemonSender(string host = "127.0.0.1", int port = 6789) { daemon = new IPEndPoint(IPAddress.Parse(host), port); IsReady = true; + _ = ReadRepliesAsync(reading.Token); } public bool IsReady { get; private set; } - public Task SendAsync(string text, CancellationToken cancellation = default) => - WriteAsync(Encoding.ASCII.GetBytes(text.ToUpperInvariant()), cancellation); + public bool ReportsCompletion => true; + + public event EventHandler? Finished; + + public async Task SendAsync(string text, CancellationToken cancellation = default) + { + await WriteAsync(Escape('h', ReplyToken), cancellation).ConfigureAwait(false); + await WriteAsync(Encoding.ASCII.GetBytes(text.ToUpperInvariant()), cancellation) + .ConfigureAwait(false); + } public Task AbortAsync(CancellationToken cancellation = default) => WriteAsync([0x1B, (byte)'4'], cancellation); @@ -29,11 +48,44 @@ public sealed class CwDaemonSender : MessageSender public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) => WriteAsync(Escape('2', wordsPerMinute.ToString()), cancellation); - public void Dispose() => socket.Dispose(); + public void Dispose() + { + reading.Cancel(); + reading.Dispose(); + socket.Dispose(); + } private static byte[] Escape(char command, string argument) => [0x1B, (byte)command, .. Encoding.ASCII.GetBytes(argument)]; + /// cwdaemon's reply arrives on the same socket the commands went out on. + /// A reply for anything other than our own token is another program's + /// business and is passed over. + private async Task ReadRepliesAsync(CancellationToken cancellation) + { + while (!cancellation.IsCancellationRequested) + { + UdpReceiveResult received; + try + { + received = await socket.ReceiveAsync(cancellation).ConfigureAwait(false); + } + catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException) + { + return; + } + catch (SocketException) + { + // a datagram that could not be read says nothing about the next + continue; + } + if (Encoding.ASCII.GetString(received.Buffer).TrimEnd('\r', '\n') == $"h{ReplyToken}") + { + Finished?.Invoke(this, EventArgs.Empty); + } + } + } + private async Task WriteAsync(byte[] message, CancellationToken cancellation) { try diff --git a/src/Nonemm.Keying/MessageSender.cs b/src/Nonemm.Keying/MessageSender.cs index 8ea3648..30d18dd 100644 --- a/src/Nonemm.Keying/MessageSender.cs +++ b/src/Nonemm.Keying/MessageSender.cs @@ -6,6 +6,16 @@ public interface MessageSender : IDisposable { bool IsReady { get; } + /// True when the keyer tells us the message has gone out. Alternating CQ + /// needs it: timing a message from the length of its text is a guess that + /// goes wrong exactly when the contest is busy. + bool ReportsCompletion { get; } + + /// Everything sent has now gone out on the air. Never raised by a keyer + /// whose `ReportsCompletion` is false. It arrives on whatever thread the + /// keyer reads on, so a handler that touches the screen has to post. + event EventHandler? Finished; + /// Sends the text. Whatever is already going out is finished first unless /// `Abort` is called. Task SendAsync(string text, CancellationToken cancellation = default); diff --git a/src/Nonemm.Keying/WinkeyerSender.cs b/src/Nonemm.Keying/WinkeyerSender.cs index 699a384..3f21dc4 100644 --- a/src/Nonemm.Keying/WinkeyerSender.cs +++ b/src/Nonemm.Keying/WinkeyerSender.cs @@ -15,14 +15,22 @@ public sealed class WinkeyerSender : MessageSender private const byte ClearBuffer = 0x0A; private readonly SerialPort port; + private readonly WinkeyerStatus status = new(); public WinkeyerSender(string portName, int baudRate = 1200) { port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.Two); + port.DataReceived += (_, _) => ReadStatus(); } public bool IsReady => port.IsOpen; + /// The keyer sends a status byte of its own accord whenever it starts or + /// stops sending, so nothing has to be asked for. + public bool ReportsCompletion => true; + + public event EventHandler? Finished; + /// Opens the port and puts the keyer in host mode. The keyer answers with /// its firmware version, which is read and thrown away. public void Open() @@ -61,6 +69,19 @@ public sealed class WinkeyerSender : MessageSender port.Dispose(); } + /// Runs on the serial port's own thread. + private void ReadStatus() + { + int waiting = port.BytesToRead; + for (int at = 0; at < waiting; at++) + { + if (status.Read((byte)port.ReadByte())) + { + Finished?.Invoke(this, EventArgs.Empty); + } + } + } + private void Write(byte[] message) { if (!port.IsOpen) diff --git a/src/Nonemm.Keying/WinkeyerStatus.cs b/src/Nonemm.Keying/WinkeyerStatus.cs new file mode 100644 index 0000000..02df841 --- /dev/null +++ b/src/Nonemm.Keying/WinkeyerStatus.cs @@ -0,0 +1,33 @@ +namespace Nonemm.Keying; + +/// Reads the bytes a WinKeyer sends back in host mode and works out when it has +/// stopped sending. +/// +/// A byte from 0xC0 to 0xDF is a status byte; the low six bits are the flags, +/// and 0x04 is set while the keyer is sending. Bytes from 0x80 to 0xBF are the +/// speed pot, and printable bytes are the keyer echoing what it has sent. +/// Neither says anything about the buffer, so both are passed over. +/// +/// Where the bit meanings come from: `docs/keying.md`. +public sealed class WinkeyerStatus +{ + private const byte StatusLow = 0xC0; + private const byte StatusHigh = 0xDF; + private const byte BusyFlag = 0x04; + + public bool IsSending { get; private set; } + + /// True on the byte that says the keyer has finished: it was sending and + /// now is not. Every other byte returns false, including a second status + /// byte that repeats what the last one said. + public bool Read(byte value) + { + if (value is < StatusLow or > StatusHigh) + { + return false; + } + bool wasSending = IsSending; + IsSending = (value & BusyFlag) != 0; + return wasSending && !IsSending; + } +} diff --git a/src/Nonemm.Session/AlternatingCq.cs b/src/Nonemm.Session/AlternatingCq.cs new file mode 100644 index 0000000..a0ac6d7 --- /dev/null +++ b/src/Nonemm.Session/AlternatingCq.cs @@ -0,0 +1,147 @@ +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. +/// +/// 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. +/// +/// Two radios, because that is what an SO2R station has. +public sealed class AlternatingCq : IDisposable +{ + private readonly MessageSender keyer; + private readonly Func callCqOn; + private readonly Func wait; + private readonly Lock gate = new(); + private CancellationTokenSource? running; + + /// `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( + MessageSender keyer, + Func callCqOn, + TimeSpan gap, + Func? wait = null) + { + this.keyer = keyer; + this.callCqOn = callCqOn; + Gap = gap; + this.wait = wait ?? Task.Delay; + 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; } + + /// Raised when it stops by itself, with the reason. Stopping by hand does + /// not raise it. + public event EventHandler? Stopped; + + /// True when the keyer can drive this at all. + public bool IsPossible => 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; + _ = CallAsync(radioNumber, token); + } + + public void Stop() + { + lock (gate) + { + running?.Cancel(); + running?.Dispose(); + running = null; + } + } + + public void Dispose() + { + keyer.Finished -= OnFinished; + Stop(); + } + + private void OnFinished(object? sender, EventArgs e) + { + CancellationToken token; + lock (gate) + { + if (running is null) + { + 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); + } + } + } +} diff --git a/src/Nonemm.Session/Nonemm.Session.csproj b/src/Nonemm.Session/Nonemm.Session.csproj index f94e5d6..90aa192 100644 --- a/src/Nonemm.Session/Nonemm.Session.csproj +++ b/src/Nonemm.Session/Nonemm.Session.csproj @@ -6,6 +6,7 @@ + diff --git a/tests/Nonemm.Keying.Tests/CwDaemonSenderTests.cs b/tests/Nonemm.Keying.Tests/CwDaemonSenderTests.cs index 0defbaf..2103a47 100644 --- a/tests/Nonemm.Keying.Tests/CwDaemonSenderTests.cs +++ b/tests/Nonemm.Keying.Tests/CwDaemonSenderTests.cs @@ -27,11 +27,49 @@ public class CwDaemonSenderTests : IDisposable } [Fact] - public async Task TextGoesOutInUpperCase() + public async Task TextGoesOutInUpperCaseBehindAReplyRequest() { using CwDaemonSender sender = new("127.0.0.1", Port); - byte[] sent = await NextDatagram(() => sender.SendAsync("cq test de dl1abc")); - Assert.Equal("CQ TEST DE DL1ABC", Encoding.ASCII.GetString(sent)); + await sender.SendAsync("cq test de dl1abc"); + + // one receive at a time: two at once can take the datagrams in either order + UdpReceiveResult request = await daemon.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5)); + UdpReceiveResult text = await daemon.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal("\u001bhnonemm", Encoding.ASCII.GetString(request.Buffer)); + Assert.Equal("CQ TEST DE DL1ABC", Encoding.ASCII.GetString(text.Buffer)); + } + + /// cwdaemon answers with `h` and the text of the reply request once the + /// message has been played. + [Fact] + public async Task TheReplySaysTheMessageHasGoneOut() + { + using CwDaemonSender sender = new("127.0.0.1", Port); + TaskCompletionSource finished = new(); + sender.Finished += (_, _) => finished.TrySetResult(); + + Task request = daemon.ReceiveAsync(); + await sender.SendAsync("test"); + UdpReceiveResult from = await request.WaitAsync(TimeSpan.FromSeconds(5)); + await daemon.SendAsync(Encoding.ASCII.GetBytes("hnonemm\r\n"), from.RemoteEndPoint); + + await finished.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task AReplyForSomebodyElseIsPassedOver() + { + using CwDaemonSender sender = new("127.0.0.1", Port); + TaskCompletionSource finished = new(); + sender.Finished += (_, _) => finished.TrySetResult(); + + Task request = daemon.ReceiveAsync(); + await sender.SendAsync("test"); + UdpReceiveResult from = await request.WaitAsync(TimeSpan.FromSeconds(5)); + await daemon.SendAsync(Encoding.ASCII.GetBytes("hsomebodyelse\r\n"), from.RemoteEndPoint); + + await Assert.ThrowsAsync( + () => finished.Task.WaitAsync(TimeSpan.FromMilliseconds(300))); } [Fact] diff --git a/tests/Nonemm.Keying.Tests/WinkeyerStatusTests.cs b/tests/Nonemm.Keying.Tests/WinkeyerStatusTests.cs new file mode 100644 index 0000000..c850e70 --- /dev/null +++ b/tests/Nonemm.Keying.Tests/WinkeyerStatusTests.cs @@ -0,0 +1,49 @@ +namespace Nonemm.Keying.Tests; + +public class WinkeyerStatusTests +{ + [Fact] + public void SendingEndsWhenTheBusyFlagClears() + { + WinkeyerStatus status = new(); + + Assert.False(status.Read(0xC4)); + Assert.True(status.IsSending); + Assert.True(status.Read(0xC0)); + Assert.False(status.IsSending); + } + + [Fact] + public void AStatusByteThatRepeatsTheLastOneIsNotAnEnding() + { + WinkeyerStatus status = new(); + status.Read(0xC4); + status.Read(0xC0); + + Assert.False(status.Read(0xC0)); + } + + [Fact] + public void ClearingTheBusyFlagWithTheBreakInFlagSetStillEndsIt() + { + WinkeyerStatus status = new(); + status.Read(0xC4); + + Assert.True(status.Read(0xC2)); + } + + /// The speed pot and the characters the keyer echoes say nothing about the + /// buffer. + [Theory] + [InlineData(0x80)] + [InlineData(0xBF)] + [InlineData((byte)'K')] + public void OtherBytesAreNotStatus(byte value) + { + WinkeyerStatus status = new(); + status.Read(0xC4); + + Assert.False(status.Read(value)); + Assert.True(status.IsSending); + } +} diff --git a/tests/Nonemm.Session.Tests/AlternatingCqTests.cs b/tests/Nonemm.Session.Tests/AlternatingCqTests.cs new file mode 100644 index 0000000..8a3155b --- /dev/null +++ b/tests/Nonemm.Session.Tests/AlternatingCqTests.cs @@ -0,0 +1,136 @@ +using Nonemm.Keying; + +namespace Nonemm.Session.Tests; + +public class AlternatingCqTests +{ + /// A keyer that sends nothing and says it has finished when the test says + /// so, so the alternation is what is under test rather than a timer. + private sealed class FakeKeyer : MessageSender + { + public bool IsReady => true; + + public bool ReportsCompletion { get; init; } = true; + + public event EventHandler? Finished; + + public void FinishMessage() => Finished?.Invoke(this, EventArgs.Empty); + + public Task SendAsync(string text, CancellationToken cancellation = default) => + Task.CompletedTask; + + public Task AbortAsync(CancellationToken cancellation = default) => Task.CompletedTask; + + public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) => + Task.CompletedTask; + + public void Dispose() + { + } + } + + private static readonly Func NoWait = + (_, _) => Task.CompletedTask; + + private static AlternatingCq Driving(FakeKeyer keyer, List called) => + new( + keyer, + radio => + { + called.Add(radio); + return Task.CompletedTask; + }, + TimeSpan.Zero, + NoWait); + + [Fact] + public void StartingCallsOnTheRadioItWasGiven() + { + FakeKeyer keyer = new(); + List called = []; + using AlternatingCq cq = Driving(keyer, called); + + cq.Start(2); + + Assert.Equal([2], called); + Assert.True(cq.IsRunning); + Assert.Equal(2, cq.RadioNumber); + } + + [Fact] + public void TheOtherRadioCallsOnceTheMessageHasGoneOut() + { + FakeKeyer keyer = new(); + List called = []; + using AlternatingCq cq = Driving(keyer, called); + + cq.Start(1); + keyer.FinishMessage(); + keyer.FinishMessage(); + keyer.FinishMessage(); + + Assert.Equal([1, 2, 1, 2], called); + } + + [Fact] + public void StoppingLeavesTheKeyerAlone() + { + FakeKeyer keyer = new(); + List called = []; + using AlternatingCq cq = Driving(keyer, called); + + cq.Start(1); + cq.Stop(); + keyer.FinishMessage(); + + Assert.Equal([1], called); + Assert.False(cq.IsRunning); + } + + /// The message that was already going out when the operator stopped still + /// reports itself finished, and that must not start the other radio. + [Fact] + public void AMessageFinishingAfterAStopStartsNothing() + { + FakeKeyer keyer = new(); + List called = []; + using AlternatingCq cq = Driving(keyer, called); + + cq.Start(1); + keyer.FinishMessage(); + cq.Stop(); + keyer.FinishMessage(); + + Assert.Equal([1, 2], called); + } + + [Fact] + public void AKeyerThatCannotReportCompletionCannotDriveIt() + { + FakeKeyer keyer = new() { ReportsCompletion = false }; + List called = []; + using AlternatingCq cq = Driving(keyer, called); + + Assert.False(cq.IsPossible); + Assert.Throws(() => cq.Start(1)); + Assert.Empty(called); + } + + [Fact] + public void AKeyerThatHasGoneAwayStopsTheWholeThing() + { + FakeKeyer keyer = new(); + List reasons = []; + using AlternatingCq cq = new( + keyer, + _ => throw new InvalidOperationException("could not reach cwdaemon at 127.0.0.1:6789"), + TimeSpan.Zero, + NoWait); + cq.Stopped += (_, reason) => reasons.Add(reason); + + cq.Start(1); + + Assert.False(cq.IsRunning); + Assert.Equal(["could not reach cwdaemon at 127.0.0.1:6789"], reasons); + } +}