Add CW keying, function key messages and the documentation
The function keys send through cwdaemon or a WinKeyer, with N1MM's message macros. Escape stops sending. Settings are read with the reflection serializer rather than a generated one: the generated one hands back null for every property the file leaves out instead of the value the property is declared with, which crashed the program the first time a new setting was added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
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.
|
||||
public sealed class CwDaemonSender : MessageSender
|
||||
{
|
||||
private readonly UdpClient socket = new();
|
||||
private readonly IPEndPoint daemon;
|
||||
|
||||
public CwDaemonSender(string host = "127.0.0.1", int port = 6789)
|
||||
{
|
||||
daemon = new IPEndPoint(IPAddress.Parse(host), port);
|
||||
IsReady = true;
|
||||
}
|
||||
|
||||
public bool IsReady { get; private set; }
|
||||
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
||||
WriteAsync(Encoding.ASCII.GetBytes(text.ToUpperInvariant()), cancellation);
|
||||
|
||||
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() => socket.Dispose();
|
||||
|
||||
private static byte[] Escape(char command, string argument) =>
|
||||
[0x1B, (byte)command, .. Encoding.ASCII.GetBytes(argument)];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Nonemm.Keying;
|
||||
|
||||
/// Something that sends a message on the air: a CW keyer, or a voice keyer
|
||||
/// playing a recording.
|
||||
public interface MessageSender : IDisposable
|
||||
{
|
||||
bool IsReady { get; }
|
||||
|
||||
/// Sends the text. Whatever is already going out is finished first unless
|
||||
/// `Abort` is called.
|
||||
Task SendAsync(string text, CancellationToken cancellation = default);
|
||||
|
||||
/// Stops sending straight away, which is what Escape does mid-message.
|
||||
Task AbortAsync(CancellationToken cancellation = default);
|
||||
|
||||
Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default);
|
||||
}
|
||||
@@ -6,4 +6,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.IO.Ports" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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;
|
||||
|
||||
public WinkeyerSender(string portName, int baudRate = 1200)
|
||||
{
|
||||
port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.Two);
|
||||
}
|
||||
|
||||
public bool IsReady => port.IsOpen;
|
||||
|
||||
/// 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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user