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 lines = Channel.CreateUnbounded(); private readonly TimeSpan startPatience; private TaskCompletionSource? 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? Received; public event EventHandler? TransmitChanged; public event EventHandler? ConnectionChanged; /// Anything the bridge says about itself, for the status line. public event EventHandler? Reported; /// The answer to `AskBufferedAsync`: how many characters the engine still /// has to transmit. public event EventHandler? 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(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; }