Alternate CQ between the two radios
Ctrl+B calls CQ on one radio, and when that message has gone out, moves to the other and calls there. The keyboard, the entry window and the SO2R box follow each turn. N1MM calls this dueling CQs and puts it on the same key. The turn is taken when the keyer says the message has ended, not when a timer guesses it has. So MessageSender grew a Finished event and a ReportsCompletion flag, and both keyers fill them in: cwdaemon answers the <ESC>h reply request that now goes out in front of every message, and a WinKeyer clears the busy bit in the status bytes it sends of its own accord. The status-byte reading is in WinkeyerStatus, away from the serial port, because that is the half that can be tested without a keyer on the desk. A keyer that reports nothing refuses to start alternating CQ rather than keying the second radio over the first. AlternatingCq itself takes the keyer, a callback that calls CQ on a radio, the gap and a wait function, so the alternation is tested without sleeping. The gap is in Config ▸ Keyer and messages and will not go below 100 ms, which is N1MM's floor too: an SO2R box works relays. docs/keying.md writes down why cwdaemon does the timing and we do not. N1MM keys DTR itself with a coarse sleep, a busy-wait and a margin that grows every time the sleep overshoots, and it raises the thread to TIME_CRITICAL for the length of the message. The busy-wait ports to Linux; the priority does not, without CAP_SYS_NICE, and a garbage collection mid-element is audible. A direct serial keyer stays a reasonable third option, to be taken knowingly. Running it against a fake daemon that takes 1.5 seconds to play a message: six CQs went out back to back and the keyboard moved between the two entry windows each time. Escape stopped it, let the message in flight finish, and started nothing further. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
///
|
||||
/// `<ESC>h<text>` 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user