Files
Nonemm/src/Nonemm.Keying/WinkeyerSender.cs
ericek111 ae48c04e71 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>
2026-08-27 23:28:57 +00:00

94 lines
2.6 KiB
C#

using System.IO.Ports;
using System.Text;
namespace Nonemm.Keying;
/// Sends CW through a WinKeyer on a serial port. The keyer is opened in host
/// mode, which is the state where it takes text rather than following a paddle
/// alone.
public sealed class WinkeyerSender : MessageSender
{
private const byte AdminCommand = 0x00;
private const byte HostOpen = 0x02;
private const byte HostClose = 0x03;
private const byte SetSpeed = 0x02;
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()
{
port.Open();
port.Write([AdminCommand, HostOpen], 0, 2);
Thread.Sleep(100);
port.DiscardInBuffer();
}
public Task SendAsync(string text, CancellationToken cancellation = default)
{
Write(Encoding.ASCII.GetBytes(text.ToUpperInvariant()));
return Task.CompletedTask;
}
public Task AbortAsync(CancellationToken cancellation = default)
{
Write([ClearBuffer]);
return Task.CompletedTask;
}
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default)
{
Write([SetSpeed, (byte)Math.Clamp(wordsPerMinute, 5, 99)]);
return Task.CompletedTask;
}
public void Dispose()
{
if (port.IsOpen)
{
port.Write([AdminCommand, HostClose], 0, 2);
port.Close();
}
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)
{
throw new InvalidOperationException($"the keyer on {port.PortName} is not open");
}
port.Write(message, 0, message.Length);
}
}