Add the digital interface, running MMTTY under Wine
MMTTY and 2Tone have no socket or pipe interface: N1MM hosts XMMT.ocx and exchanges window messages with the engine. A Linux process cannot load that control, so bridge/nonemm-mmtty-bridge.exe hosts it under Wine and passes lines over its standard input and output. docs/digital-bridge.md states the protocol and what the control needs. The window is N1MM's: receive pane with coloured callsigns, grab list, call stacking, twenty-four macro buttons and the engine controls. One left click copies what is under it — a callsign to the callsign box, anything else to the exchange box the contest keeps for that kind of value. Config > Digital registers XMMT.ocx in the Wine prefix on its own, making the prefix first if it is not there. The engine, the bridge and the control start at the copies shipped beside the program. The bridge reads the control's own events. OnTranslateMessage carries only the messages the control has no event for, so nothing was ever decoded through it. hamlib's data mode names read as digital modes now, and a mode typed into the callsign box changes mode the way a frequency changes band, so a station with no radio can reach RTTY at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
220
src/Nonemm.Digital/MmttyEngine.cs
Normal file
220
src/Nonemm.Digital/MmttyEngine.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// MMTTY or 2Tone, started and driven through the bridge. The startup is
|
||||
/// N1MM's: the engine is given a title, the PTT port out of Mmtty.INI and a
|
||||
/// command line, and an engine that fails to initialise is started again up to
|
||||
/// ten times, which is what N1MM's retry does.
|
||||
public sealed class MmttyEngine : DigitalEngine
|
||||
{
|
||||
private const int StartAttempts = 10;
|
||||
|
||||
private readonly BridgeChannel bridge;
|
||||
private readonly MmttyOptions options;
|
||||
private readonly Channel<string> lines = Channel.CreateUnbounded<string>();
|
||||
private readonly TimeSpan startPatience;
|
||||
private TaskCompletionSource<bool>? starting;
|
||||
private MmttySettings settings = new();
|
||||
private string command = "";
|
||||
private int attempts;
|
||||
private Task? reading;
|
||||
|
||||
public MmttyEngine(BridgeChannel bridge, MmttyOptions options, TimeSpan? startPatience = null)
|
||||
{
|
||||
this.bridge = bridge;
|
||||
this.options = options;
|
||||
this.startPatience = startPatience ?? TimeSpan.FromSeconds(30);
|
||||
bridge.LineReceived += (_, line) => lines.Writer.TryWrite(line);
|
||||
bridge.Failed += (_, why) => Stopped(why);
|
||||
}
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public bool IsTransmitting { get; private set; }
|
||||
|
||||
/// The version MMTTY reported when it connected.
|
||||
public string Version { get; private set; } = "";
|
||||
|
||||
public int MarkHertz { get; private set; }
|
||||
|
||||
public int SpaceHertz { get; private set; }
|
||||
|
||||
/// MMTTY's AFC, net and reverse bits. Kept here because a toggle is sent as
|
||||
/// the whole word, so the current one has to be known.
|
||||
public int Switches { get; private set; }
|
||||
|
||||
public event EventHandler<string>? Received;
|
||||
|
||||
public event EventHandler<bool>? TransmitChanged;
|
||||
|
||||
public event EventHandler<bool>? ConnectionChanged;
|
||||
|
||||
/// Anything the bridge says about itself, for the status line.
|
||||
public event EventHandler<string>? Reported;
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
settings = MmttySettings.Read(options.SettingsPath);
|
||||
Switches = settings.Switches;
|
||||
await bridge.StartAsync(cancellation).ConfigureAwait(false);
|
||||
command = options.CommandLine(
|
||||
await bridge.ToWindowsPathAsync(options.EnginePath, cancellation).ConfigureAwait(false));
|
||||
starting = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
attempts = 1;
|
||||
reading ??= Task.Run(ReadAsync, CancellationToken.None);
|
||||
await OpenAsync(cancellation).ConfigureAwait(false);
|
||||
await starting.Task.WaitAsync(startPatience, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(BridgeLine.Write("send", text), cancellation);
|
||||
|
||||
/// One typed character, which MMTTY transmits as it arrives.
|
||||
public Task TypeAsync(char character, CancellationToken cancellation = default) =>
|
||||
PostAsync(MmttyMessage.TypeCharacter, character, cancellation);
|
||||
|
||||
/// N1MM's abort, which is what its RX button and Escape do: drop PTT and
|
||||
/// let the engine stop where it is.
|
||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||
SetPttAsync(false, cancellation);
|
||||
|
||||
/// N1MM's `{TX}` and `{RX}`. MMTTY sends what is in its buffer before it
|
||||
/// drops PTT, so a macro that ends with `{RX}` still goes out in full.
|
||||
public Task SetPttAsync(bool on, CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(BridgeLine.Write("ptt", on ? "1" : "0"), cancellation);
|
||||
|
||||
public Task ReturnToReceiveAsync(CancellationToken cancellation = default) =>
|
||||
SetPttAsync(false, cancellation);
|
||||
|
||||
/// Moves the demodulator, keeping the shift the engine reported.
|
||||
public async Task TuneAsync(int markHertz, CancellationToken cancellation = default)
|
||||
{
|
||||
int shift = SpaceHertz - MarkHertz;
|
||||
await PostAsync(MmttyMessage.MarkFrequency, markHertz, cancellation).ConfigureAwait(false);
|
||||
await PostAsync(MmttyMessage.SpaceFrequency, markHertz + shift, cancellation)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task SetSwitchesAsync(int switches, CancellationToken cancellation = default) =>
|
||||
PostAsync(MmttyMessage.Switches, switches, cancellation);
|
||||
|
||||
public Task PostAsync(MmttyMessage message, int parameter, CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(
|
||||
BridgeLine.Write("post", ((int)message).ToString(), parameter.ToString()),
|
||||
cancellation);
|
||||
|
||||
/// Shuts the engine down and stops the bridge.
|
||||
public async Task StopAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
await bridge.SendAsync(BridgeLine.Write("quit"), cancellation).ConfigureAwait(false);
|
||||
if (reading is not null)
|
||||
{
|
||||
lines.Writer.TryComplete();
|
||||
await reading.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lines.Writer.TryComplete();
|
||||
bridge.Dispose();
|
||||
}
|
||||
|
||||
private Task OpenAsync(CancellationToken cancellation) =>
|
||||
bridge.SendAsync(
|
||||
BridgeLine.Write("open", options.Title, PttPort(), command),
|
||||
cancellation);
|
||||
|
||||
private string PttPort() => options.PttPort ?? settings.PttPort;
|
||||
|
||||
private async Task ReadAsync()
|
||||
{
|
||||
await foreach (string line in lines.Reader.ReadAllAsync().ConfigureAwait(false))
|
||||
{
|
||||
(string verb, string[] fields) = BridgeLine.Read(line);
|
||||
await ActOnAsync(verb, fields).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ActOnAsync(string verb, string[] fields)
|
||||
{
|
||||
switch (verb)
|
||||
{
|
||||
case "connected":
|
||||
Version = Field(fields, 0);
|
||||
IsConnected = true;
|
||||
starting?.TrySetResult(true);
|
||||
ConnectionChanged?.Invoke(this, true);
|
||||
break;
|
||||
case "disconnected":
|
||||
await DisconnectedAsync(Number(fields, 0)).ConfigureAwait(false);
|
||||
break;
|
||||
case "rx":
|
||||
Received?.Invoke(this, ((char)Number(fields, 0)).ToString());
|
||||
break;
|
||||
case "tx":
|
||||
IsTransmitting = Number(fields, 0) == 1;
|
||||
TransmitChanged?.Invoke(this, IsTransmitting);
|
||||
break;
|
||||
case "mark":
|
||||
MarkHertz = Number(fields, 0);
|
||||
break;
|
||||
case "space":
|
||||
SpaceHertz = Number(fields, 0);
|
||||
break;
|
||||
case "switch":
|
||||
Switches = Number(fields, 0);
|
||||
break;
|
||||
case "error":
|
||||
Stopped(Field(fields, 0));
|
||||
break;
|
||||
case "log":
|
||||
Reported?.Invoke(this, Field(fields, 0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Status 2 is MMTTY failing to come up, which happens often enough that
|
||||
/// N1MM starts it again rather than telling the operator.
|
||||
private async Task DisconnectedAsync(int status)
|
||||
{
|
||||
if (status != 2)
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionChanged?.Invoke(this, false);
|
||||
return;
|
||||
}
|
||||
if (attempts >= StartAttempts)
|
||||
{
|
||||
Stopped($"{options.EnginePath} failed to start after {attempts} tries");
|
||||
return;
|
||||
}
|
||||
attempts++;
|
||||
Reported?.Invoke(this, $"the engine failed to start, trying again ({attempts})");
|
||||
await OpenAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void Stopped(string why)
|
||||
{
|
||||
if (starting?.TrySetException(new InvalidOperationException(why)) == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (IsConnected)
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionChanged?.Invoke(this, false);
|
||||
}
|
||||
Reported?.Invoke(this, why);
|
||||
}
|
||||
|
||||
private static string Field(string[] fields, int index) =>
|
||||
index < fields.Length ? fields[index] : "";
|
||||
|
||||
private static int Number(string[] fields, int index) =>
|
||||
int.TryParse(Field(fields, index), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
Reference in New Issue
Block a user