The macro buttons went wrong at the end of a transmission, both faults in
DigitalEngineSender:
- The engine reports the transmitter drop on its own thread.
WhenTransmitChanged took the state lock, decided the message had ended,
released the lock, and only then called Buffer.Ended(), which clears
what has gone to the engine. A macro pressed on the last character got
through StartAsync in that gap and had already flushed its own text into
Sent, so Ended() wiped the new text off the pane while the engine
transmitted it. Ended() is now called inside the same lock.
- The pane only started again when the engine reported a drop. Two macros
in a row keep the transmitter up, so that report never came and Sent
grew with every press. Everything in Sent is locked, because it is in
the engine and cannot be taken back, so the whole pane became
read-only. TypeAhead.Started() drops the last message's sent text and
keeps what was typed ahead, and StartAsync calls it whenever it keys a
new transmission.
The rest of this commit is the digital transmit work these fixes sit on:
the pane as one coloured box, the sender's three keying states, the
type-ahead feeder paced by the clock with the engine's count as a brake,
{RX} flushing what is left in one piece, and the entry window's function
keys reading the digital macros on a digital mode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
251 lines
9.6 KiB
C#
251 lines
9.6 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.
|
|
///
|
|
/// It holds a buffer of its own and says how much of it is left, so the
|
|
/// type-ahead feeds it one character at a time the way N1MM does and paces on
|
|
/// the count.
|
|
public sealed class MmttyEngine : DigitalEngine, EngineBuffer
|
|
{
|
|
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);
|
|
|
|
/// The control's `PTT` property back to false, which is the key the other
|
|
/// way rather than a stop. N1MM never does this: it keys with the property
|
|
/// and leaves `SetMmttyPTT(1)` to drop the transmitter.
|
|
public Task ReleaseKeyAsync(CancellationToken cancellation = default) =>
|
|
bridge.SendAsync(BridgeLine.Write("key", "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 KeyAsync(CancellationToken cancellation = default) =>
|
|
SetPttAsync(true, 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;
|
|
}
|