Nothing was ever transmitted. SetMmttyPTT does not start a transmission: N1MM
calls it with 1 to stop once the buffer is empty, which is its XmitOff, and with
0 to stop now, which is its AbortXmit. A transmission starts by setting the
control's PTT property, in XmitOn.
So the bridge learns `key <0|1>` for that property, and MmttyEngine now keys
with it, ends a message with SetMmttyPTT(1) so the buffer still goes out, and
aborts with SetMmttyPTT(0). This is the digital {TX} in the entry window and the
digital window as well as the probe: none of them could key the engine before.
The probe checks that the engine keys before it measures anything, and stops
with a plain statement if it does not, rather than reporting numbers from an
engine sitting still. It also asks a new question: whether the engine holds a
word until the space after it, which is MMTTY's Way to send. Received characters
are marked as noise while the engine is not transmitting, since a machine with a
sound card decodes the band all the way through the run.
The first run on a real engine says MMTTY 1.70 connects, the control answers
TxBufLen and refuses NotAProperty with DISP_E_UNKNOWNNAME. What TxBufLen counts
is still open: read while nothing was transmitting it rose over time and rose by
four after four backspaces, which is not what characters-left would do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
238 lines
8.9 KiB
C#
238 lines
8.9 KiB
C#
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;
|
|
|
|
/// The answer to `AskBufferedAsync`: how many characters the engine still
|
|
/// has to transmit.
|
|
public event EventHandler<int>? Buffered;
|
|
|
|
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);
|
|
|
|
/// Asks how many characters the engine still holds. The control has no
|
|
/// event for it, so it is polled; the answer arrives on `Buffered`. A
|
|
/// property name other than `TxBufLen` is only for finding out what the
|
|
/// control answers to.
|
|
public Task AskBufferedAsync(string property = "", CancellationToken cancellation = default) =>
|
|
bridge.SendAsync(BridgeLine.Write("buffer", property), cancellation);
|
|
|
|
/// Escape and the RX button: stop now and leave what has not gone out
|
|
/// unsent. N1MM's `AbortXmit`.
|
|
public Task AbortAsync(CancellationToken cancellation = default) =>
|
|
bridge.SendAsync(BridgeLine.Write("ptt", "0"), cancellation);
|
|
|
|
/// N1MM's `{TX}` and `{RX}`. Keying is the control's `PTT` property;
|
|
/// unkeying is `SetMmttyPTT(1)`, which waits for the buffer to empty first,
|
|
/// so a macro that ends with `{RX}` still goes out in full.
|
|
public Task SetPttAsync(bool on, CancellationToken cancellation = default) =>
|
|
bridge.SendAsync(
|
|
on ? BridgeLine.Write("key", "1") : BridgeLine.Write("ptt", "1"),
|
|
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 "buffer":
|
|
Buffered?.Invoke(this, Number(fields, 0));
|
|
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;
|
|
}
|