using System.Net; using System.Net.Sockets; using System.Text; 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 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 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); public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) => WriteAsync(Escape('2', wordsPerMinute.ToString()), cancellation); 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 { await socket.SendAsync(message, daemon, cancellation).ConfigureAwait(false); } catch (SocketException e) { IsReady = false; throw new InvalidOperationException($"could not reach cwdaemon at {daemon}: {e.Message}", e); } } }