Start the transmit pane again on every new message

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
This commit is contained in:
2026-09-03 14:40:30 +00:00
co-authored by Claude Opus 5
parent 7ae60be4a0
commit f49a8c10fd
20 changed files with 2604 additions and 390 deletions
+2 -1
View File
@@ -329,7 +329,8 @@ public sealed class AppSession : IDisposable
WineBridgeChannel channel = new(
Settings.DigitalBridgePath,
Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null,
Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine");
Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine",
Paths.Diagnostics);
MmttyEngine started = new(channel, options);
await started.StartAsync();
digital = started;
@@ -0,0 +1,40 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Nonemm.App.Controls">
<Style Selector="controls|TransmitPane">
<Setter Property="Background" Value="{DynamicResource FieldBackground}" />
<Setter Property="Foreground" Value="{DynamicResource FieldForeground}" />
<Setter Property="CaretBrush" Value="{DynamicResource FieldForeground}" />
<Setter Property="SelectionBrush" Value="#3399FF" />
<Setter Property="SelectionForegroundBrush" Value="#FFFFFF" />
<Setter Property="Padding" Value="3,2" />
<Setter Property="Template">
<!-- a TextBox subclass gets no theme of its own, so the pane carries
its own template. It is the Fluent one cut down to what the pane
uses: no watermark, no clear button, no border of its own -->
<ControlTemplate>
<ScrollViewer Name="PART_ScrollViewer"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<controls:TransmitPresenter Name="PART_TextPresenter"
Text="{TemplateBinding Text}"
CaretIndex="{TemplateBinding CaretIndex}"
SelectionStart="{TemplateBinding SelectionStart}"
SelectionEnd="{TemplateBinding SelectionEnd}"
SelectionBrush="{TemplateBinding SelectionBrush}"
SelectionForegroundBrush="{TemplateBinding SelectionForegroundBrush}"
CaretBrush="{TemplateBinding CaretBrush}"
TextAlignment="{TemplateBinding TextAlignment}"
TextWrapping="{TemplateBinding TextWrapping}"
LineHeight="{TemplateBinding LineHeight}"
LetterSpacing="{TemplateBinding LetterSpacing}"
SentLength="{TemplateBinding SentLength}"
SentBrush="{TemplateBinding SentBrush}"
VerticalAlignment="Top" />
</ScrollViewer>
</ControlTemplate>
</Setter>
</Style>
</Styles>
+37
View File
@@ -0,0 +1,37 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace Nonemm.App.Controls;
/// The digital transmit pane: one editable box in which the first `SentLength`
/// characters — what has already gone to the engine — are drawn in `SentBrush`.
///
/// Avalonia's TextBox draws all of its text in one brush, so this was a label
/// beside a box before. That took width from the box as the label grew and the
/// label did not wrap. A TextBox builds its text through a TextPresenter, and
/// the TextLayout under it does take a brush per run, so the pane is a TextBox
/// with `TransmitPresenter` in place of the plain presenter. The template in
/// `DigitalWindow.axaml` is what puts it there.
public class TransmitPane : TextBox
{
public static readonly StyledProperty<int> SentLengthProperty =
AvaloniaProperty.Register<TransmitPane, int>(nameof(SentLength));
public static readonly StyledProperty<IBrush?> SentBrushProperty =
AvaloniaProperty.Register<TransmitPane, IBrush?>(nameof(SentBrush));
/// How many characters at the front of the text have gone out.
public int SentLength
{
get => GetValue(SentLengthProperty);
set => SetValue(SentLengthProperty, value);
}
/// What those characters are drawn in.
public IBrush? SentBrush
{
get => GetValue(SentBrushProperty);
set => SetValue(SentBrushProperty, value);
}
}
@@ -0,0 +1,133 @@
using Avalonia;
using Avalonia.Controls.Presenters;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
namespace Nonemm.App.Controls;
/// The presenter behind `TransmitPane`: the same text layout Avalonia builds
/// for a TextBox, with the first `SentLength` characters given `SentBrush`.
///
/// TextPresenter builds its layout in `CreateTextLayout` and already passes
/// per-run overrides for the selection, so this adds one more run to that list.
/// The selection has to keep its own colour on top, which is why the coloured
/// run is cut around it rather than laid over it: overlapping runs are not
/// defined.
public class TransmitPresenter : TextPresenter
{
public static readonly StyledProperty<int> SentLengthProperty =
AvaloniaProperty.Register<TransmitPresenter, int>(nameof(SentLength));
public static readonly StyledProperty<IBrush?> SentBrushProperty =
AvaloniaProperty.Register<TransmitPresenter, IBrush?>(nameof(SentBrush));
/// The width the layout is built to. `TextPresenter` keeps its own copy and
/// does not hand it out, so it is read off the measure pass here.
private Size constraint;
public int SentLength
{
get => GetValue(SentLengthProperty);
set => SetValue(SentLengthProperty, value);
}
public IBrush? SentBrush
{
get => GetValue(SentBrushProperty);
set => SetValue(SentBrushProperty, value);
}
protected override Size MeasureOverride(Size availableSize)
{
constraint = availableSize;
return base.MeasureOverride(availableSize);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == SentLengthProperty || change.Property == SentBrushProperty)
{
InvalidateTextLayout();
}
}
protected override TextLayout CreateTextLayout()
{
string text = Text ?? "";
int sent = Math.Clamp(SentLength, 0, text.Length);
// nothing to colour, or a case the base class handles on its own: the
// password character replaces the text, and a preedit run is the input
// method's, not ours
if (sent == 0 || SentBrush is null || PasswordChar != '\0'
|| !string.IsNullOrEmpty(PreeditText))
{
return base.CreateTextLayout();
}
Typeface typeface = new(FontFamily, FontStyle, FontWeight, FontStretch);
// a zero constraint is a measure with no bound, which is infinity to
// the layout
double width = constraint.Width > 0 ? constraint.Width : double.PositiveInfinity;
double height = constraint.Height > 0 ? constraint.Height : double.PositiveInfinity;
return new TextLayout(
text,
typeface,
FontSize,
Foreground,
TextAlignment,
TextWrapping,
null,
null,
FlowDirection,
width,
height,
LineHeight,
LetterSpacing,
0,
FontFeatures,
Runs(typeface, sent));
}
/// The coloured runs, in order and not overlapping. The selection, when
/// there is one with a colour of its own, cuts the coloured run in two.
private List<ValueSpan<TextRunProperties>> Runs(Typeface typeface, int sent)
{
int from = Math.Min(SelectionStart, SelectionEnd);
int to = Math.Max(SelectionStart, SelectionEnd);
bool selected = ShowSelectionHighlight && to > from && SelectionForegroundBrush is not null;
List<ValueSpan<TextRunProperties>> runs = [];
Add(runs, 0, selected ? Math.Min(sent, from) : sent, SentBrush, typeface);
if (selected)
{
Add(runs, from, to, SelectionForegroundBrush, typeface);
Add(runs, to, sent, SentBrush, typeface);
}
return runs;
}
private void Add(
List<ValueSpan<TextRunProperties>> runs,
int start,
int end,
IBrush? brush,
Typeface typeface)
{
if (end <= start)
{
return;
}
runs.Add(new ValueSpan<TextRunProperties>(
start,
end - start,
new GenericTextRunProperties(
typeface,
FontSize,
null,
brush,
null,
BaselineAlignment.Baseline,
null,
FontFeatures)));
}
}
+122 -53
View File
@@ -127,17 +127,26 @@ public sealed partial class DigitalWindow
}
}
/// What is typed in the transmit pane goes into the type-ahead buffer,
/// which feeds the engine. A carriage return is what the engine takes as a
/// new line, so Enter puts one in rather than the newline the box would.
/// What is typed in the transmit pane goes into the type-ahead buffer.
/// Nothing goes on the air until the transmitter is keyed, which is the TX
/// button, Ctrl+Enter or Alt+T; from then on what is typed goes out as it
/// is typed. Enter is a new line in the message, which the engine takes as
/// a carriage return.
///
/// Escape stops now: what has not gone to the engine is dropped and the
/// engine drops what it holds.
private void OnTransmitKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.Control)
{
e.Handled = true;
StartTransmit();
return;
}
if (e.Key == Key.Enter)
{
e.Handled = true;
int at = Math.Clamp(TransmitBox.CaretIndex, 0, TransmitBox.Text?.Length ?? 0);
TransmitBox.Text = (TransmitBox.Text ?? "").Insert(at, "\r");
TransmitBox.CaretIndex = at + 1;
Type("\r");
return;
}
if (e.Key == Key.Escape)
@@ -148,37 +157,61 @@ public sealed partial class DigitalWindow
}
}
/// The operator rewrote what has not gone out yet. Only the box holds it;
/// what is already in the engine is in the label beside it and cannot be
/// reached from here.
/// Puts text in at the caret, never before the text that has gone out.
private void Type(string text)
{
string was = TransmitBox.Text ?? "";
int at = Math.Clamp(TransmitBox.CaretIndex, locked, was.Length);
TransmitBox.Text = was.Insert(at, text);
TransmitBox.CaretIndex = at + text.Length;
}
/// The operator rewrote the pane. What has already gone to the engine
/// cannot be taken back, so an edit that reaches into it is undone; the
/// rest goes to the buffer, which sends it if the transmitter is up and
/// holds it if it is not.
private void OnTransmitTextChanged(object? sender, TextChangedEventArgs e)
{
if (showingBuffer || session.DigitalKeyer is not { } keyer)
{
return;
}
keyer.Buffer.Rewrite(TransmitBox.Text ?? "", CursorInBox());
}
/// The cursor holds the pump back: nothing behind it goes out, so the
/// engine idles rather than transmitting text the operator is still
/// typing. With the box out of focus there is no cursor to hold anything.
private void OnTransmitFocus(object? sender, RoutedEventArgs e) => ShowCursor();
private void ShowCursor()
{
if (session.DigitalKeyer is { } keyer)
string now = TransmitBox.Text ?? "";
if (FirstDifference(pane, now) < locked)
{
keyer.Buffer.Cursor = TransmitBox.IsFocused ? CursorInBox() : TypeAhead.NoCursor;
showingBuffer = true;
try
{
TransmitBox.Text = pane;
TransmitBox.CaretIndex = locked;
}
finally
{
showingBuffer = false;
}
return;
}
pane = now;
keyer.Buffer.Edit(now);
}
private int CursorInBox() =>
Math.Clamp(TransmitBox.CaretIndex, 0, TransmitBox.Text?.Length ?? 0);
/// Where two versions of the pane first differ, which is the length of both
/// when one is the other with text added or taken off the end.
private static int FirstDifference(string was, string now)
{
int most = Math.Min(was.Length, now.Length);
int at = 0;
while (at < most && was[at] == now[at])
{
at++;
}
return at;
}
/// Draws the buffer: what has gone out in the label, what is still to go in
/// the box. The pump takes characters off the front, so the cursor moves
/// back with them and the operator can go on typing while it does.
/// Draws the buffer: what has gone out and what is still to go, as one
/// text, with the length of the first half telling the pane how much of it
/// to colour. A character moving from one half to the other leaves the text
/// the same, so the caret and what the operator is typing do not move.
private void ShowBuffer()
{
if (session.DigitalKeyer is not { } keyer)
@@ -188,20 +221,29 @@ public sealed partial class DigitalWindow
showingBuffer = true;
try
{
SentText.Text = keyer.Buffer.Sent;
string pending = keyer.Buffer.Pending;
string was = TransmitBox.Text ?? "";
if (was == pending)
string sent = keyer.Buffer.Sent;
string now = sent + keyer.Buffer.Pending;
locked = sent.Length;
// only what the engine has transmitted is coloured. What it is
// still holding cannot be taken back either, but the operator has
// not heard it go yet, and marking it as gone turned every
// character red as it was typed once the transmission caught up
TransmitBox.SentLength = keyer.Buffer.OnAir;
if ((TransmitBox.Text ?? "") == now)
{
pane = now;
return;
}
int taken = was.Length > pending.Length
&& was.EndsWith(pending, StringComparison.Ordinal)
? was.Length - pending.Length
: 0;
int caret = TransmitBox.CaretIndex;
TransmitBox.Text = pending;
TransmitBox.CaretIndex = Math.Clamp(caret - taken, 0, pending.Length);
// the end of a message drops what has gone out off the front of
// the pane, so the caret moves back with the text it is in
string was = TransmitBox.Text ?? "";
int dropped = was.Length > now.Length && was.EndsWith(now, StringComparison.Ordinal)
? was.Length - now.Length
: 0;
int caret = TransmitBox.CaretIndex - dropped;
TransmitBox.Text = now;
TransmitBox.CaretIndex = Math.Clamp(caret, locked, now.Length);
pane = now;
}
finally
{
@@ -211,7 +253,8 @@ public sealed partial class DigitalWindow
/// N1MM's keys for the digital window: Alt+T turns the transmitter on and
/// puts the cursor where what is typed goes out, Ctrl+K does the same, and
/// Alt+G takes the next call off the grab list.
/// Alt+G takes the next call off the grab list. Escape stops now, wherever
/// the focus is.
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.T)
@@ -235,14 +278,15 @@ public sealed partial class DigitalWindow
if (e.Key == Key.Escape)
{
e.Handled = true;
_ = session.DigitalKeyer?.AbortAsync();
_ = Running()?.AbortAsync();
return;
}
base.OnKeyDown(e);
}
/// Alt+T: on to transmit with the cursor in the transmit pane, off back to
/// receive.
/// Alt+T: on to transmit with the cursor in the transmit pane, off to drop
/// the transmitter at the end of what is waiting.
private void ToggleTransmit()
{
if (Running() is not { } running)
@@ -251,26 +295,51 @@ public sealed partial class DigitalWindow
}
if (running.IsTransmitting)
{
_ = session.DigitalKeyer?.AbortAsync();
_ = running.ReturnToReceiveAsync();
ReturnToReceive();
return;
}
_ = running.SetPttAsync(true);
StartTransmit();
}
private void OnTransmit(object? sender, RoutedEventArgs e) => StartTransmit();
/// Keys the transmitter and opens the gate, so what is in the pane goes out
/// and so does whatever is typed into it after this. The keyer does the
/// keying, so the engine is never fed before it is keyed.
private void StartTransmit()
{
if (Running() is not { } running)
{
return;
}
if (session.DigitalKeyer is { } keyer)
{
keyer.Transmit();
}
else
{
_ = running.SetPttAsync(true);
}
TransmitBox.Focus();
}
private void OnTransmit(object? sender, RoutedEventArgs e)
{
_ = Running()?.SetPttAsync(true);
TransmitBox.Focus();
}
/// The RX button is the `{RX}` macro by hand: the transmitter drops at the
/// end of what is waiting rather than in the middle of it. Escape is what
/// stops now.
private void OnReceive(object? sender, RoutedEventArgs e) => ReturnToReceive();
/// The RX button stops where it is: what has not gone to the engine is
/// dropped, and the engine drops what it holds.
private void OnReceive(object? sender, RoutedEventArgs e)
private void ReturnToReceive()
{
_ = session.DigitalKeyer?.AbortAsync();
_ = Running()?.AbortAsync();
if (Running() is not { } running)
{
return;
}
if (session.DigitalKeyer is { } keyer)
{
keyer.ReturnToReceiveWhenSent();
return;
}
_ = running.ReturnToReceiveAsync();
}
private void OnClearTransmit(object? sender, RoutedEventArgs e)
+10 -11
View File
@@ -1,6 +1,7 @@
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Nonemm.App.Windows"
xmlns:controls="using:Nonemm.App.Controls"
x:Class="Nonemm.App.Windows.DigitalWindow"
Title="Digital Interface" Width="900" Height="640">
<Window.Styles>
@@ -33,6 +34,7 @@
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="4,2" />
</Style>
<StyleInclude Source="avares://Nonemm.App/Controls/TransmitPane.axaml" />
</Window.Styles>
<DockPanel>
@@ -166,17 +168,14 @@
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,150">
<Border BorderThickness="1" BorderBrush="#40808080">
<!-- what has gone out, then what is still to go: the first is a
label so it cannot be edited, the second the box the operator
types in -->
<Grid ColumnDefinitions="Auto,*">
<TextBlock Name="SentText" FontFamily="monospace" Margin="3,3,0,0"
VerticalAlignment="Top" />
<TextBox Grid.Column="1" Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
FontFamily="monospace" BorderThickness="0"
KeyDown="OnTransmitKeyDown" TextChanged="OnTransmitTextChanged"
GotFocus="OnTransmitFocus" LostFocus="OnTransmitFocus" />
</Grid>
<!-- one box for the whole message: what has gone out is coloured and
cannot be edited, what is still to go is typed into the same
text, so the two wrap together and neither takes width from the
other -->
<controls:TransmitPane Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
FontFamily="monospace"
KeyDown="OnTransmitKeyDown"
TextChanged="OnTransmitTextChanged" />
</Border>
<Grid Grid.Column="2" RowDefinitions="*,Auto">
<Border BorderThickness="1" BorderBrush="#40808080">
+15 -29
View File
@@ -59,6 +59,15 @@ public sealed partial class DigitalWindow : RefreshableWindow
/// redraw is not read back as an edit by the operator.
private bool showingBuffer;
/// The transmit pane as it was last drawn, so an edit can be told from a
/// redraw and the changed character found.
private string pane = "";
/// How much of the pane is in the engine's hands and cannot be edited. It
/// is more than what is coloured, which is only what has gone out over the
/// air.
private int locked;
/// The buffer this window is drawing, or null while no engine is running.
private TypeAhead? buffer;
@@ -82,9 +91,6 @@ public sealed partial class DigitalWindow : RefreshableWindow
entry.Activated += WhenEntryActivated;
BuildMacros();
ApplySettings();
// the cursor holds the pump back, so it has to follow the caret as it
// moves, not only as the text changes
TransmitBox.GetObservable(TextBox.CaretIndexProperty).Subscribe(new Watcher(ShowCursor));
Attach(session.Digital);
Refresh();
Closed += (_, _) =>
@@ -104,8 +110,8 @@ public sealed partial class DigitalWindow : RefreshableWindow
{
ReceiveScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
GrabScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
SentText.Foreground = Transmitted;
SentText.FontSize = Settings.DigitalFontSize;
TransmitBox.Background = Themes.Brush(Themes.Current.FieldBackground);
TransmitBox.SentBrush = Transmitted;
TransmitBox.FontSize = Settings.DigitalFontSize;
if (buffer is not null)
{
@@ -188,21 +194,16 @@ public sealed partial class DigitalWindow : RefreshableWindow
private void WhenBufferChanged(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(ShowBuffer);
/// The pane starts empty for the next message: the transmitter dropping
/// with nothing left to send is the end of this one. An engine that keys
/// itself drops between two characters as well, and that text is left
/// where it is.
/// The transmitter dropping ends the message. The buffer clears what has
/// gone out and keeps what the operator typed ahead, so the pane is left
/// with the next message in it rather than empty.
private void WhenTransmitChanged(object? sender, bool transmitting) =>
Dispatcher.UIThread.Post(() =>
{
TransmitDot.Background = transmitting
? Themes.Brush(Themes.Current.TransmitLight)
: Brushes.Transparent;
if (!transmitting && buffer is { IsSending: false })
{
buffer.Clear();
ShowBuffer();
}
ShowBuffer();
});
private void WhenConnectionChanged(object? sender, bool connected) =>
@@ -568,18 +569,3 @@ public sealed partial class DigitalWindow : RefreshableWindow
_ => StackOrder.Disabled,
};
}
/// Watches one property. Avalonia hands out observables and this program has no
/// other use for Rx, so a handler that takes no value is enough.
internal sealed class Watcher(Action changed) : IObserver<int>
{
public void OnCompleted()
{
}
public void OnError(Exception error)
{
}
public void OnNext(int value) => changed();
}
+69 -13
View File
@@ -35,13 +35,33 @@ public sealed partial class EntryWindow
/// The twelve keys for this radio, which are a different twelve while
/// running and while searching, as N1MM's file holds them.
///
/// On a digital mode they come from the digital macros instead, which is
/// where N1MM reads them from as well: its entry window loads the RTTYBTN
/// set there rather than the CW file. Without this the keys held CW text
/// with no `{TX}` in it, so pressing one fed the engine without keying the
/// transmitter and nothing went on the air.
private IReadOnlyList<FunctionKey> Keys() =>
Messages
.For(
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
session.Settings.CwMessageFile,
session.Settings.PhoneMessageFile)
.Keys(Logging?.IsRunning ?? false);
Logging?.Mode.Category == ModeCategory.Digital
? DigitalKeys()
: Messages
.For(
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
session.Settings.CwMessageFile,
session.Settings.PhoneMessageFile)
.Keys(Logging?.IsRunning ?? false);
/// The first ten digital macros, then Spot and Wipe. F11 and F12 are what
/// they are in every other mode, so the labels say what the buttons do;
/// the macros past the tenth are on the digital window's own buttons.
private IReadOnlyList<FunctionKey> DigitalKeys()
{
IReadOnlyList<FunctionKey> macros = Messages.Digital(session.Settings.DigitalMessageFile).Buttons;
List<FunctionKey> keys = [.. macros.Take(MessageFile.KeyCount - 2)];
keys.Add(new FunctionKey("Spot", ""));
keys.Add(new FunctionKey("Wipe", ""));
return keys;
}
/// Right-clicking a function key button opens the messages for the mode the
/// radio is in, which is where import and export live too.
@@ -140,7 +160,12 @@ public sealed partial class EntryWindow
}
if (plan.Text.Length == 0)
{
// a button that only acts: {WIPE}, {LOG}, {RUN}
// a button that only acts: {WIPE}, {LOG}, {RUN}, or an {RX} on its
// own, which has nothing to wait for
foreach (MessageAction action in plan.After)
{
Run(action);
}
return true;
}
if ((through ?? Sender) is not { } keyer)
@@ -170,7 +195,22 @@ public sealed partial class EntryWindow
Status(e.Message);
return;
}
_ = RunWhenSentAsync(keyer, after);
// `{RX}` goes to the keyer as soon as the message is in it, which is
// N1MM's ending: the text in one piece, the stop 400 ms behind it, and
// the engine unkeys itself at the last character. Waiting for the
// message to go out first leaves the engine empty, and a stop that
// arrives there does nothing. Everything else after `{END}` still
// waits.
foreach (MessageAction action in after)
{
if (action.Command == MessageCommand.ReturnToReceive)
{
Run(action);
}
}
_ = RunWhenSentAsync(
keyer,
[.. after.Where(action => action.Command != MessageCommand.ReturnToReceive)]);
}
/// Waits for the keyer, turns the transmit light off, and runs whatever
@@ -292,14 +332,30 @@ public sealed partial class EntryWindow
Logging.Stack.Clear();
ShowCallStack();
break;
// the digital engine keys itself while it has text to send, so
// {TX} and {RX} only matter when a macro wants the transmitter
// held open around what it sends
// {TX} keys the transmitter and {RX} drops it. {RX} waits until
// everything waiting has gone out, so it ends the transmission
// rather than cutting it off
case MessageCommand.StartTransmit:
_ = session.Digital?.SetPttAsync(true);
// through the keyer, which keys the engine before it feeds it
// the message
if (session.DigitalKeyer is { } keying)
{
keying.Transmit();
}
else
{
_ = session.Digital?.SetPttAsync(true);
}
break;
case MessageCommand.ReturnToReceive:
_ = session.Digital?.ReturnToReceiveAsync();
if (session.DigitalKeyer is { } digital)
{
digital.ReturnToReceiveWhenSent();
}
else
{
_ = session.Digital?.ReturnToReceiveAsync();
}
break;
}
}
+14
View File
@@ -28,4 +28,18 @@ public interface DigitalEngine : IDisposable
/// Drops whatever has not gone out yet, which is what Escape does.
Task AbortAsync(CancellationToken cancellation = default);
/// N1MM's `{TX}`: the transmitter comes up now, before there is anything
/// to send.
Task KeyAsync(CancellationToken cancellation = default);
/// N1MM's `{RX}`: the transmitter drops once the modem has transmitted what
/// it still holds.
Task ReturnToReceiveAsync(CancellationToken cancellation = default);
/// The key back down, without waiting for anything. MMTTY needs this: its
/// `SetMmttyPTT(1)` leaves the transmitter up whatever N1MM's source says,
/// so `{RX}` waits for the modem to say its buffer is empty and then puts
/// the key down here.
Task ReleaseKeyAsync(CancellationToken cancellation = default);
}
+320 -15
View File
@@ -8,15 +8,71 @@ namespace Nonemm.Digital;
///
/// A message goes into the type-ahead buffer rather than to the engine whole,
/// so the part that has not gone out can still be rewritten. `Finished` is
/// raised when the buffer has run dry and the engine has transmitted what it
/// was given.
/// raised when the buffer has run dry, with the last characters of the message
/// in the engine and about a third of a second of it still to go out.
///
/// An engine that can say how much it still has to transmit paces the buffer;
/// the rest are paced by the clock at `baud`.
/// The buffer is paced by the clock at `baud`, and the engine's own count of
/// what it has left is a brake on it.
///
/// Three states, which is what the transmitter can be doing:
///
/// | State | Key | Gate | Engine |
/// |---|---|---|---|
/// | `Down` | down | shut | holds nothing |
/// | `Keyed` | up | open, the feeder paces text over | holds `Ahead` characters |
/// | `Ending` | up | shut, everything left was flushed in one piece | holds the rest of the message |
///
/// | From | Event | To |
/// |---|---|---|
/// | `Down` | `Transmit`, or a message to send | `Keyed` |
/// | `Keyed` | more text | `Keyed` |
/// | `Keyed` | `{RX}` | `Ending` |
/// | `Keyed` | the engine drops with the message unfinished | `Keyed`, keyed again |
/// | `Ending` | the engine drops | `Down` |
/// | `Ending` | the engine never drops | `Down`, the key put down here |
/// | `Ending` | `Transmit`, or a message to send | `Keyed`, once the engine is empty and its stop is cleared |
/// | any | `AbortAsync` | `Down` |
///
/// The last two rows are why there is a state at all rather than a flag.
/// Ending a message takes as long as the engine takes to transmit what it
/// holds, and the operator can key again inside that time. An ending that went
/// on running put the key down in the middle of the message after it.
///
/// Coming back out of `Ending` is not free either. The stop is inside the
/// engine by then, waiting for its buffer to empty, and MMTTY fed while that
/// stands stayed keyed and transmitted nothing: the characters went in and
/// never came out. So a new message waits for what the engine holds to go out,
/// clears the stop with an abort, and keys again.
public sealed class DigitalEngineSender : MessageSender
{
/// How long the engine may make no progress at all, after it has been told
/// to stop, before the key goes down anyway. It is not a limit on the whole
/// wait: a flushed message is seconds of transmission and the engine is
/// entitled to all of it. Measured against the whole wait instead, it cut
/// a CQ off with 21 symbols still in the engine.
public static readonly TimeSpan StopPatience = TimeSpan.FromSeconds(1.5);
/// The longest the stop waits for the engine to say it has the message.
/// The stop does nothing at all if it arrives at an engine with an empty
/// buffer, so it goes out as soon as the count says there is something to
/// stop, and after this long whether the count says so or not. It is
/// N1MM's number, which N1MM sleeps outright.
public static readonly TimeSpan StopDelay = TimeSpan.FromMilliseconds(400);
private readonly DigitalEngine engine;
private readonly Lock gate = new();
/// Where the transmitter is, as far as this program knows.
private Keying state = Keying.Down;
/// Which transmission this is. It goes up whenever one starts or is
/// abandoned, and the ending of a message checks it after every step: an
/// ending belongs to one transmission and must not act on the next one.
/// Without it the ending of one message put the key down in the middle of
/// the message after it.
private int transmission;
public DigitalEngineSender(DigitalEngine engine, double baud = TypeAhead.DefaultBaud)
{
this.engine = engine;
@@ -25,7 +81,7 @@ public sealed class DigitalEngineSender : MessageSender
: new TypeAhead(
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
baud);
Buffer.Drained += WhenDrained;
Buffer.Given += WhenGiven;
engine.TransmitChanged += WhenTransmitChanged;
}
@@ -40,16 +96,157 @@ public sealed class DigitalEngineSender : MessageSender
public event EventHandler? Finished;
public Task SendAsync(string text, CancellationToken cancellation = default)
/// A message to send. Text is the operator asking for the transmitter, so
/// it keys as well: text arriving while the last message was ending
/// abandons that ending, and text arriving with the transmitter down brings
/// it up rather than letting MMTTY key itself off the first character.
public async Task SendAsync(string text, CancellationToken cancellation = default)
{
await StartAsync(cancellation).ConfigureAwait(false);
Buffer.Append(text);
return Task.CompletedTask;
}
/// Escape and the RX button: what has not gone to the engine is dropped,
/// and the engine drops what it still holds.
private async Task TransmitAsync()
{
await StartAsync().ConfigureAwait(false);
Buffer.Transmit();
}
/// The TX button, Ctrl+Enter and `{TX}`: the transmitter comes up and what
/// is in the pane goes out, and so does whatever is typed into it from now
/// on.
///
/// Keying belongs here rather than in the window so that it is always the
/// first thing the engine is told. MMTTY keys itself off a character it is
/// given while the transmitter is down, sends it and drops again, so a key
/// that arrives behind the text puts a keyed-up gap in the middle of a
/// message.
public void Transmit() => _ = TransmitAsync();
/// N1MM's `{RX}`: everything still waiting goes to the engine in one piece
/// and the engine is then asked to stop. A macro that ends with it goes out
/// in full, and so does anything the operator has typed ahead of it.
///
/// It does not wait for the feeder to hand the message over first. Waiting
/// is what made the stop useless: by the time the last character had gone
/// over, the engine was empty again, and MMTTY ignores a stop that reaches
/// it empty.
public void ReturnToReceiveWhenSent() => _ = StopAsync();
/// A transmission begins, or the one that was ending goes on. True when the
/// engine has to be keyed, which is every time but one already keyed and
/// still going.
private async Task StartAsync(CancellationToken cancellation = default)
{
bool ending;
lock (gate)
{
ending = state == Keying.Ending;
}
if (ending)
{
// the last message is ending and its stop is inside the engine,
// waiting for the buffer to empty. Feeding an engine with that
// standing left MMTTY keyed with nothing going out and the
// characters swallowed, so what it still holds is let out and the
// stop is cleared with an abort before it is keyed again
await WaitUntilAiredAsync(StopPatience).ConfigureAwait(false);
await engine.AbortAsync(cancellation).ConfigureAwait(false);
}
bool key;
lock (gate)
{
key = state != Keying.Keyed;
if (key)
{
transmission++;
}
state = Keying.Keyed;
}
if (key)
{
// the pane starts again on the message that has gone out. The
// engine reporting the drop does this too, but it never reports one
// when a message follows the last close enough to keep the
// transmitter up, and the pane then held the whole run with none of
// it editable
Buffer.Started();
await engine.KeyAsync(cancellation).ConfigureAwait(false);
}
}
/// Waits until the engine says it holds something, or `patience` runs out.
/// The stop does nothing at an engine with an empty buffer, so it goes out
/// as soon as there is something to stop. N1MM sleeps 400 ms here instead,
/// which is the same wait without the question: it never reads the count.
/// A sleep is also wrong on a short message — `TU` is 330 ms of air at
/// 45.45 baud, so 400 ms of it puts the stop back where it does nothing.
private async Task WaitUntilHoldingAsync(TimeSpan patience)
{
DateTime giveUp = DateTime.UtcNow + patience;
while (Buffer.EngineHolds <= 0 && DateTime.UtcNow < giveUp)
{
await Task.Delay(TypeAhead.PollInterval).ConfigureAwait(false);
}
}
/// Waits until the engine has transmitted everything it was given, or until
/// it has made no progress for `patience`.
///
/// `patience` is not a limit on the whole wait: a flushed message is
/// seconds of transmission and the engine is entitled to all of it. What it
/// catches is an engine that has stopped moving. Measured against the whole
/// wait, it cut a CQ off with 21 symbols still in the engine.
private async Task WaitUntilAiredAsync(TimeSpan patience)
{
if (Buffer.Outstanding <= 0 && Buffer.EngineHolds <= 0 && !Buffer.IsSending)
{
return;
}
TaskCompletionSource aired = new(TaskCreationOptions.RunContinuationsAsynchronously);
void WhenAired(object? sender, EventArgs e) => aired.TrySetResult();
Buffer.Aired += WhenAired;
try
{
int holds = Buffer.EngineHolds;
int outstanding = Buffer.Outstanding;
DateTime giveUp = DateTime.UtcNow + patience;
while (!aired.Task.IsCompleted && DateTime.UtcNow < giveUp)
{
await Task.Delay(TypeAhead.PollInterval).ConfigureAwait(false);
if (Buffer.EngineHolds < holds || Buffer.Outstanding < outstanding)
{
holds = Buffer.EngineHolds;
outstanding = Buffer.Outstanding;
giveUp = DateTime.UtcNow + patience;
}
}
}
finally
{
Buffer.Aired -= WhenAired;
}
}
/// True while `mine` is still the transmission being ended. False once
/// something has started another one, ended this one, or aborted.
private bool Ending(int mine)
{
lock (gate)
{
return state == Keying.Ending && transmission == mine;
}
}
/// Escape: what has not gone to the engine is dropped, and the engine drops
/// what it still holds.
public Task AbortAsync(CancellationToken cancellation = default)
{
lock (gate)
{
state = Keying.Down;
transmission++;
}
Buffer.Drop();
return engine.AbortAsync(cancellation);
}
@@ -61,26 +258,134 @@ public sealed class DigitalEngineSender : MessageSender
public void Dispose()
{
Buffer.Drained -= WhenDrained;
Buffer.Given -= WhenGiven;
Buffer.Dispose();
engine.TransmitChanged -= WhenTransmitChanged;
}
private void WhenDrained(object? sender, EventArgs e) => Finished?.Invoke(this, EventArgs.Empty);
/// The message is finished when the last character has been handed to the
/// engine, not when the engine has transmitted it. What stands after
/// `{END}` runs there, `{RX}` among it, and `{RX}` has to reach the engine
/// while the engine still holds something to send.
private void WhenGiven(object? sender, EventArgs e) => Finished?.Invoke(this, EventArgs.Empty);
/// The engine dropping the transmitter ends the message, but only when
/// there is nothing left to send: an engine that keys itself off what it is
/// given drops between two characters of a message the pump is still
/// feeding, and that is not the end of anything.
/// Tells the engine to stop, waits for it to transmit what it still holds,
/// and puts the key down.
///
/// This is N1MM's ending, which is not the same as its keying. N1MM hands
/// MMTTY the whole message with `SendString` and calls `SetMmttyPTT(1)`
/// with the message still in the engine, and MMTTY ends the transmission
/// itself at the last character. So the feeding stops here: what is left
/// goes over in one piece and the engine is asked to stop on a full buffer.
/// Nothing after the flush can be rewritten, which is what `{RX}` means.
///
/// What sits between the two is the count, not a sleep. Two of N1MM's three
/// MMTTY send paths call the stop straight after the text and the third
/// sleeps 400 ms first, which is a fixed wait for something this program
/// can ask about: the stop goes out as soon as the engine says it holds
/// something. A fixed sleep is also wrong on a short message — `TU` is
/// 330 ms of air at 45.45 baud, so 400 ms of sleeping puts the stop back
/// where it does nothing.
///
/// The key going down is the fallback for an engine that ignores the stop,
/// which is what MMTTY did every time it was asked on an empty buffer. It
/// waits for the engine's own count to reach 0 and one symbol time on
/// top, which covers the count being read every `TypeAhead.PollInterval`
/// rather than the transmission. Every millisecond here is turnaround time
/// in a contest.
private async Task StopAsync()
{
int mine;
lock (gate)
{
if (state == Keying.Down)
{
return;
}
state = Keying.Ending;
mine = transmission;
}
string rest = await Buffer
.FlushAsync((text, cancellation) => engine.SendAsync(text, cancellation))
.ConfigureAwait(false);
if (rest.Length > 0 && Ending(mine))
{
await WaitUntilHoldingAsync(StopDelay).ConfigureAwait(false);
}
if (!Ending(mine))
{
return;
}
await engine.ReturnToReceiveAsync().ConfigureAwait(false);
await WaitUntilAiredAsync(StopPatience).ConfigureAwait(false);
if (!Ending(mine))
{
return;
}
await Task.Delay(Buffer.SymbolTime).ConfigureAwait(false);
if (Ending(mine))
{
await engine.ReleaseKeyAsync().ConfigureAwait(false);
}
}
/// The engine dropping the transmitter ends the message, but only after
/// `{RX}` or an abort asked it to stop. The pane then starts again: what
/// has gone out is cleared, what the operator typed ahead is kept, and
/// nothing more is fed until the transmitter is keyed again.
///
/// A drop nobody asked for is the engine keying itself off what it is
/// given, which happens between the characters of a message that is still
/// going out, and is not the end of anything.
private void WhenTransmitChanged(object? sender, bool transmitting)
{
if (transmitting)
{
return;
}
bool unfinished = Buffer.IsTransmitting && Buffer.IsSending;
bool ended;
lock (gate)
{
ended = state == Keying.Ending;
if (ended)
{
state = Keying.Down;
transmission++;
// inside the lock, so a message starting at this moment cannot
// have its own text cleared by the end of the one before it.
// The drop arrives from the engine on its own thread: a macro
// pressed on the last character of a message got as far as
// handing its text to the engine before this ran, and the pane
// was then wiped while the text went out
Buffer.Ended();
}
}
if (!ended)
{
// the engine dropped in the middle of a message. What is left of it
// would go out into a transmitter that is down, so it comes back
// up; the engine keying itself off the next character would leave
// that character half sent
if (unfinished)
{
_ = engine.KeyAsync();
}
return;
}
if (!Buffer.IsSending)
{
Finished?.Invoke(this, EventArgs.Empty);
}
}
/// Where the transmitter is. `Ending` is one message: everything left of it
/// has gone to the engine and nothing more is fed, and the engine is
/// transmitting what it holds.
private enum Keying
{
Down,
Keyed,
Ending,
}
}
+14 -1
View File
@@ -7,7 +7,11 @@ namespace Nonemm.Digital;
/// 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
///
/// 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;
@@ -91,6 +95,12 @@ public sealed class MmttyEngine : DigitalEngine
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.
@@ -99,6 +109,9 @@ public sealed class MmttyEngine : DigitalEngine
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);
+602 -159
View File
@@ -5,90 +5,154 @@ namespace Nonemm.Digital;
/// The text waiting to go out, held here rather than handed to the engine in
/// one piece.
///
/// A digital engine takes a whole message and transmits it at the baud rate,
/// which is slow: a callsign and a report take several seconds. Once the engine
/// has a character nothing can take it back — MMTTY treats a backspace as
/// another character to transmit rather than as an edit, which the engine probe
/// showed on the air. So the message is held here and the engine is given
/// `Lead` characters at a time: one being transmitted and one behind it, so it
/// never runs dry and never transmits idle in the middle of a message, while
/// everything further back can still be rewritten, added to or deleted.
/// A digital engine transmits at the baud rate, which is slow: a callsign and a
/// report take several seconds. Once the engine has a character nothing can
/// take it back — MMTTY treats a backspace as another character to transmit
/// rather than as an edit, which the engine probe showed on the air. So the
/// message is held here and the engine is kept just short of running dry, and
/// everything behind that can still be rewritten, added to or deleted.
///
/// `Sent` is what has gone to the engine and cannot be taken back. `Pending` is
/// what is still to go. `Cursor` is how much of the pending text may go out,
/// which is where the operator is typing: the pump stops when it reaches the
/// cursor, because the operator has not finished the word yet.
/// Two things happen to a character and they are not the same: it is **given**
/// to the engine, and later it **goes out** on the air. Everything here is one
/// or the other.
///
/// The pump is paced by the clock at the baud rate, and the engine's own count
/// of what it has left is a check on it rather than the pace. MMTTY answers
/// `TxBufLen` with the number of Baudot symbols it still has to transmit, which
/// is more than the characters handed to it: a digit costs a shift to figures
/// and a shift back, so `599 001` is seven characters and eleven symbols. The
/// answer also lags the engine by about a character time. A pump that believed
/// a fresh 0 would hand over a whole message in half a second and put all of it
/// beyond reach, so the count is used two ways only:
/// | Given to the engine | Gone out on the air |
/// |---|---|
/// | `Sent`, and it cannot be taken back | `OnAir`, how much of `Sent` has been transmitted |
/// | `Outstanding`, given but not out yet | |
/// | `Given` is raised when there is nothing left to give | `Aired` is raised when the engine has transmitted it too |
///
/// - as a cap. The engine holding more than `Lead + Slack` characters stops the
/// pump until it comes down, whatever the clock thinks.
/// - as the end of a message, together with the clock estimate.
/// `Pending` is the rest: what has not been given to the engine and can still
/// be rewritten. `EngineHolds` is the engine's own answer to the same question
/// as `Outstanding`, in Baudot symbols rather than characters.
///
/// A count that stops going down means the engine is holding what it has:
/// MMTTY set to Word out keeps a word until the space after it arrives. The
/// pump feeds a character anyway while that lasts, so the space gets there and
/// the word goes out.
/// Nothing goes out until `Transmit`, which is the TX button, Ctrl+Enter or a
/// function key. From then on everything in `Pending` goes out, and so does
/// anything added to it: the operator can go on typing and can press a function
/// key, and both follow what is already going. `Ended` shuts the gate again
/// without dropping what is waiting, so text typed ahead survives the end of a
/// message.
///
/// The pace is the clock at the baud rate: one character every
/// `SymbolTime`, with the engine kept `Ahead` characters ahead so it never
/// runs dry and transmits the idle tone in the middle of a word.
/// RTTY runs at a fixed speed, so the clock is right, and the
/// engine's own count of what it has left cannot replace it. The engine probe
/// shows why: `TxBufLen` read 0 for the first 150 ms after twenty-one
/// characters were pushed, and read 0 again for three seconds while the engine
/// was still holding `ABCD` on Word out. The count is only to be believed while
/// it is large and going down, and the feeder keeps the engine nearly empty,
/// so a feeder that paced on the count fed on a reading of 0 and ran ahead of
/// the air.
///
/// The count is a brake instead. `Slack` is how many Baudot symbols the engine
/// may be behind — MMTTY answers in symbols, which is more than the characters
/// it was given, because a digit costs a shift to figures and the letter after
/// it a shift back. An exchange full of digits is slower than the clock thinks,
/// and this is what stops the clock running away on it.
///
/// A count that is not going down means the engine is holding what it has:
/// MMTTY on Word out keeps a word until the space after it arrives, and that
/// space is the character the brake would hold back. So the brake lets go after
/// `HoldingPatience`.
public sealed class TypeAhead : IDisposable
{
/// A RTTY character is a start bit, five data bits and a stop bit and a
/// half.
public const double BitsPerCharacter = 7.5;
/// A Baudot symbol is a start bit, five data bits and a stop bit and a
/// half. A character is one symbol, or two when it needs a shift first.
public const double BitsPerSymbol = 7.5;
/// MMTTY's own default, and the speed nearly every RTTY contest runs at.
public const double DefaultBaud = 45.45;
/// How many characters may sit in the engine. Two is the smallest number
/// that keeps the engine transmitting without a gap: one on the air and one
/// behind it. At 45.45 baud that puts the last third of a second of the
/// message beyond reach.
public const int DefaultLead = 2;
/// How many characters the engine is kept ahead by. Two is the smallest
/// number that keeps it transmitting without a gap: one on the air and one
/// in hand for when that one finishes. An engine left with an empty buffer
/// transmits the idle tone instead, which is audible between the characters
/// of a long word. At 45.45 baud this puts the last third of a second of
/// the message beyond reach.
public const int DefaultAhead = 2;
/// How many Baudot symbols the engine may be behind the clock before the
/// feeder waits for it. Six is about a second at 45.45 baud, and well above
/// what `Ahead` characters of ordinary text come to, so the brake only
/// bites on a run of digits, which costs more symbols than the clock
/// thinks.
public const int DefaultSlack = 6;
/// How much of the text that has gone out is kept. It is there to be read
/// back, not to be a log.
public const int KeptSent = 2000;
/// `Cursor` set to this lets everything pending go out, which is where it
/// stands while the operator is not typing into the pane.
public const int NoCursor = int.MaxValue;
/// How far behind the engine's count runs. The probe pushed twenty-one
/// characters and read 0 for three answers, 100 to 150 ms, before the count
/// caught up with them. A 0 newer than this says nothing.
public static readonly TimeSpan CountLag = TimeSpan.FromMilliseconds(250);
/// How much the engine may hold above `Lead` before the pump stops feeding.
/// It covers the count's lag, measured at 100 to 150 ms, and the shift
/// symbols the engine has to transmit that the clock does not know about.
public const int Slack = 3;
/// How often the engine is asked how much it has left.
/// How often the engine is asked how much it has left, and how closely the
/// feeder follows its own clock. It is well under a character time at any
/// speed RTTY is worked at.
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
/// The longest the clock-paced pump sleeps between looks at the buffer. It
/// is what stands between the operator moving the cursor on and the next
/// character going out, so it is short against a character time.
private static readonly TimeSpan LongestTick = TimeSpan.FromMilliseconds(10);
private readonly Func<char, CancellationToken, Task> send;
private readonly EngineBuffer? counter;
private readonly Lock gate = new();
/// Held while a character or a flush is on its way to the engine, so the
/// two cannot cross. Without it a flush overtook the character the feeder
/// had already taken and was still sending, and that character went out
/// behind the rest of the message.
private readonly SemaphoreSlim handing = new(1, 1);
private readonly StringBuilder pending = new();
private readonly StringBuilder sent = new();
private CancellationTokenSource? stopping;
private Task pump = Task.CompletedTask;
private int cursor = NoCursor;
/// The engine's last answer: how many characters it still had to transmit,
/// or -1 before it has answered at all.
/// True once `Transmit` has been called and until the message ends: the
/// gate between what the operator has typed and the engine.
private bool open;
/// True while no feeder is running, so anything waiting for the end of the
/// message runs at once rather than waiting for a feeder that never starts.
private bool idle = true;
/// True once there has been nothing left to hand to the engine, so the end
/// of a message is announced once rather than at every poll after it.
private bool given = true;
/// The engine's last answer: how many symbols it still had to transmit, or
/// -1 before it has answered at all.
private int counted = -1;
/// How many answers have arrived, so the pump can tell a new one from the
/// one it has already added its own characters to.
private int answers;
/// How many characters at the front of `sent` the engine has transmitted.
private int aired;
/// The count the engine last answered, and how many symbols it has been
/// seen to drop that have not been charged to a character yet. Together
/// they are the air's own rate: a count that falls by four means four
/// symbols left the engine.
private int lastCount = -1;
private int budget;
/// When the engine was last given a character. Its count reads 0 for the
/// first 150 ms after a push, so a 0 within `CountLag` of one is the answer
/// not having caught up rather than an empty engine.
private DateTime gaveAt = DateTime.MinValue;
/// How many characters the engine holds that have not gone out yet, and
/// when the one it is transmitting now is finished. The engine transmits at
/// the baud rate, so what it holds goes out one character time apart. The
/// feeder keeps these, and a flush adds to them in one go.
///
/// They outlive the feeder, which starts and stops with the text: a feeder
/// that started after a flush and reset them forgot a whole message the
/// engine was still holding, and the key went down in the middle of it.
private int outstanding;
private DateTime nextOut = DateTime.UtcNow;
/// Whether the engine is in the figures shift, on the air and at the front
/// of the feeder. The two run apart: the feeder is `Ahead` characters in
/// front of what is being transmitted.
private bool airShift;
private bool feedShift;
public TypeAhead(Func<char, CancellationToken, Task> send, double baud = DefaultBaud)
{
@@ -97,7 +161,7 @@ public sealed class TypeAhead : IDisposable
}
/// An engine that holds a buffer of its own and can say how much of it is
/// left, which is what the pump checks the clock against.
/// left, which is what paces the feeder.
public TypeAhead(EngineBuffer engine, double baud = DefaultBaud)
: this((character, cancellation) => engine.TypeAsync(character, cancellation), baud)
{
@@ -105,23 +169,27 @@ public sealed class TypeAhead : IDisposable
engine.Buffered += WhenBuffered;
}
/// The speed the engine transmits at, which is what paces the pump.
/// The speed the engine transmits at, which is what paces the feeder.
public double Baud { get; set; }
/// How many characters may sit in the engine at once.
public int Lead { get; set; } = DefaultLead;
/// How many characters the engine is kept ahead by.
public int Ahead { get; set; } = DefaultAhead;
/// True once the engine has said how much it holds. An engine that will not
/// say is paced by the clock alone.
/// How many symbols the engine may be behind before the feeder waits.
public int Slack { get; set; } = DefaultSlack;
/// True once the engine has said how much it holds.
public bool Counts { get; private set; }
public TimeSpan CharacterTime =>
TimeSpan.FromSeconds(BitsPerCharacter / (Baud > 0 ? Baud : DefaultBaud));
/// How long one Baudot symbol takes on the air. A character takes one of
/// these, or two when the engine has to shift to figures or back first.
public TimeSpan SymbolTime =>
TimeSpan.FromSeconds(BitsPerSymbol / (Baud > 0 ? Baud : DefaultBaud));
/// How long the engine may hold what it has before the pump feeds it
/// anyway. Three characters is longer than any gap between transmitted
/// characters and short enough that a held word goes out at once.
public TimeSpan HoldingPatience => CharacterTime * 3;
/// How long the brake may hold before it lets go. Three character times is
/// longer than any gap between transmitted characters and short enough that
/// a word the engine is holding goes out at once.
public TimeSpan HoldingPatience => SymbolTime * 3;
/// What has gone to the engine.
public string Sent
@@ -135,6 +203,30 @@ public sealed class TypeAhead : IDisposable
}
}
/// How many characters of `Sent` have gone out over the air.
///
/// The engine is kept `Ahead` characters ahead, so the last characters
/// handed to it are still in its buffer. They cannot be taken back, but
/// they have not been heard yet, so the pane draws them as text still to
/// go. Without this, a character typed after the engine has caught up went
/// red as it was typed: the feeder hands it over at once, and the buffer
/// had no other measure of the air.
///
/// It is counted on the same clock as the feeder: the engine transmits at
/// the baud rate, so a character handed to an engine that is already
/// transmitting goes out one character time after the one before it, and a
/// character handed to an empty engine one character time from now.
public int OnAir
{
get
{
lock (gate)
{
return Math.Min(aired, sent.Length);
}
}
}
/// What is still to go.
public string Pending
{
@@ -147,22 +239,14 @@ public sealed class TypeAhead : IDisposable
}
}
/// How many of the pending characters may go out. The window sets it to
/// where the operator's cursor is; `NoCursor` while nobody is typing.
public int Cursor
/// True while what is waiting is being fed to the engine.
public bool IsTransmitting
{
get
{
lock (gate)
{
return cursor;
}
}
set
{
lock (gate)
{
cursor = Math.Max(0, value);
return open;
}
}
}
@@ -179,18 +263,26 @@ public sealed class TypeAhead : IDisposable
}
/// The text moved: a character went out, or a message was added. Raised on
/// the pump's thread, so a handler that touches the screen has to post.
/// the feeder's thread, so a handler that touches the screen has to post.
public event EventHandler? Changed;
/// Everything that was waiting has been handed to the engine, which still
/// holds the last `Ahead` characters of it. This is what tells the entry
/// window that a message is finished, so what stands after `{END}` runs and
/// `{RX}` reaches the engine while it has something left to send.
public event EventHandler? Given;
/// Everything that was waiting has gone out and the engine has transmitted
/// it. This is what tells the entry window that a message is finished, so
/// what stands after `{END}` runs and `{RX}` drops the transmitter at the
/// right moment.
public event EventHandler? Drained;
/// it, which is `Ahead` characters later than `Given`.
public event EventHandler? Aired;
/// A message to send. It goes on the end of what is already waiting, so two
/// function keys pressed together send one after the other rather than one
/// over the other.
/// over the other, and a function key pressed while the operator is typing
/// follows what has been typed.
///
/// A function key is the operator asking for the message, so it also opens
/// the gate: there is no second key to press.
public void Append(string text)
{
if (text.Length == 0)
@@ -200,38 +292,167 @@ public sealed class TypeAhead : IDisposable
lock (gate)
{
pending.Append(text);
if (cursor != NoCursor)
{
// text added behind the operator's cursor is still text to
// send, so the cursor moves out with it
cursor += text.Length;
}
open = true;
given = false;
}
Changed?.Invoke(this, EventArgs.Empty);
Start();
}
/// The operator rewrote what has not gone out yet.
public void Rewrite(string text, int wanted)
/// The operator rewrote the pane. `text` is the whole pane, what has gone
/// out and what is still to go, and `changedAt` is where in it the first
/// changed character is.
///
/// The text that has gone out cannot be changed, so as much of `text` as
/// matches it is dropped and the rest becomes the pending text. Reading the
/// whole pane rather than the pending half is what makes this safe against
/// the feeder: a character the feeder took between the window reading the
/// pane and this call is still at the front of `text`, and is dropped with
/// the rest of what has gone out.
///
/// An edit does not open or close the gate. Typing before the transmitter
/// is keyed stays off the air; typing while it is keyed goes out behind
/// what is already going.
public void Edit(string text)
{
lock (gate)
{
int gone = Math.Min(sent.Length, text.Length);
pending.Clear();
pending.Append(text);
cursor = Math.Clamp(wanted, 0, text.Length);
pending.Append(text, gone, text.Length - gone);
given = given && pending.Length == 0;
}
Start();
}
/// Drops what has not gone out. Escape and the RX button do this: what is
/// already in the engine cannot be stopped from here, and the engine's own
/// abort takes care of that.
/// The TX button, Ctrl+Enter and Alt+T: what is in the pane goes out, and
/// so does whatever is added to it, until the transmitter drops.
public void Transmit()
{
lock (gate)
{
open = true;
given = given && pending.Length == 0;
}
Start();
}
/// The message is over: the transmitter has dropped, so the pane starts
/// again with what has gone out cleared and whatever was typed ahead kept.
public void Ended()
{
lock (gate)
{
open = false;
sent.Clear();
aired = 0;
outstanding = 0;
}
Changed?.Invoke(this, EventArgs.Empty);
}
/// A new transmission begins. What the last one sent is dropped from the
/// pane, and what the operator typed ahead is kept. The gate is left as it
/// is: the caller opens it with `Append` or `Transmit`.
///
/// `Ended` does the same at the end of a message, but only when the engine
/// reports that the transmitter dropped. Two messages sent one after the
/// other keep the transmitter up, so that report never comes, and without
/// this the pane kept every message of the run and none of it could be
/// edited: text that has gone to the engine cannot be taken back.
public void Started()
{
bool had;
lock (gate)
{
had = sent.Length > 0;
sent.Clear();
aired = 0;
}
if (had)
{
Changed?.Invoke(this, EventArgs.Empty);
}
}
/// The engine's own answer: how many symbols it still holds, or -1 before
/// it has answered at all. It is in symbols, which is more than the
/// characters it was given, because a digit costs a shift to figures and
/// the letter after it a shift back.
public int EngineHolds => Volatile.Read(ref counted);
/// Everything still waiting goes to the engine in one piece through `push`,
/// and nothing more can be rewritten. Returns what was sent.
///
/// The feeder cannot be handing a character over at the same time: this
/// takes the same turn the feeder takes, so a character already on its way
/// arrives first and the rest follows it in order.
public async Task<string> FlushAsync(
Func<string, CancellationToken, Task> push,
CancellationToken cancellation = default)
{
await handing.WaitAsync(cancellation).ConfigureAwait(false);
try
{
string rest = TakePending();
if (rest.Length > 0)
{
await push(rest, cancellation).ConfigureAwait(false);
}
return rest;
}
finally
{
handing.Release();
}
}
/// Everything still waiting, taken in one piece and marked as gone to the
/// engine. The caller sends it, and nothing more can be rewritten.
///
/// `{RX}` uses this. N1MM hands MMTTY the whole message with `SendString`
/// and asks it to stop 400 ms later, with the message still in the engine's
/// buffer, and MMTTY ends the transmission itself at exactly the right
/// moment. `SetMmttyPTT(1)` sent to an engine that has been fed one
/// character at a time, and is therefore nearly empty, does nothing at all.
private string TakePending()
{
string rest;
lock (gate)
{
rest = pending.ToString();
pending.Clear();
sent.Append(rest);
if (sent.Length > KeptSent)
{
int dropped = sent.Length - KeptSent;
sent.Remove(0, dropped);
aired = Math.Max(0, aired - dropped);
}
given = true;
// inside the same lock as the pending text, so the feeder cannot
// see an empty buffer and call the message over before the engine
// is counted as holding what it was just given
Gave(rest.Length, DateTime.UtcNow);
}
if (rest.Length > 0)
{
Changed?.Invoke(this, EventArgs.Empty);
}
return rest;
}
/// Drops what has not gone out. Escape does this: what is already in the
/// engine cannot be stopped from here, and the engine's own abort takes
/// care of that.
public void Drop()
{
lock (gate)
{
pending.Clear();
cursor = NoCursor;
open = false;
// the engine's own abort goes with this, so it holds nothing either
outstanding = 0;
}
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -244,7 +465,8 @@ public sealed class TypeAhead : IDisposable
{
pending.Clear();
sent.Clear();
cursor = NoCursor;
aired = 0;
open = false;
}
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -258,101 +480,291 @@ public sealed class TypeAhead : IDisposable
stopping?.Cancel();
stopping?.Dispose();
stopping = null;
handing.Dispose();
}
private void Start()
{
lock (gate)
{
if (!pump.IsCompleted)
if (!idle)
{
return;
}
idle = false;
stopping?.Dispose();
stopping = new CancellationTokenSource();
pump = Task.Run(() => RunAsync(stopping.Token));
_ = Task.Run(() => RunAsync(stopping.Token));
}
}
/// Feeds the engine on the clock, holds off when its own count says it is
/// further behind than that, and stops when both agree there is nothing
/// left.
/// Hands the engine a character every character time, keeping it `Ahead`
/// characters ahead of the air, unless its own count says it is more than
/// `Slack` symbols behind and still moving.
private async Task RunAsync(CancellationToken cancellation)
{
int inEngine = 0;
int last = int.MaxValue;
int fedSince = 0;
int seen = -1;
DateTime nextOut = DateTime.MinValue;
DateTime lastAsked = DateTime.MinValue;
// when the next character is owed. It advances by exactly one character
// time per character handed over, never from the time the poll happened:
// a poll is up to `PollInterval` late, and starting the next character
// from there made every one late by a little and the engine run dry
DateTime due = DateTime.MinValue;
DateTime moved = DateTime.UtcNow;
int last = int.MaxValue;
try
{
while (!cancellation.IsCancellationRequested)
{
DateTime now = DateTime.UtcNow;
while (inEngine > 0 && now >= nextOut)
if (counter is not null)
{
inEngine--;
nextOut += CharacterTime;
}
if (counter is not null && now - lastAsked >= PollInterval)
{
lastAsked = now;
await counter.AskBufferedAsync("", cancellation).ConfigureAwait(false);
}
int answered = Volatile.Read(ref answers);
if (answered != seen)
{
seen = answered;
fedSince = 0;
}
DateTime now = DateTime.UtcNow;
int left = Volatile.Read(ref counted);
if (left < last)
{
moved = now;
}
last = left;
// the answer says nothing about the characters fed since it
int held = left < 0 ? -1 : left + fedSince;
bool holding = left > 0 && now - moved > HoldingPatience;
bool room = holding
|| (inEngine < Lead && (held < 0 || held < Lead + Slack));
if (room && Take() is { } next)
bool wentOut = Counts ? WentOutByCount(left, now) : WentOutByClock(now);
if (wentOut)
{
if (inEngine == 0)
{
nextOut = now + CharacterTime;
}
inEngine++;
fedSince++;
// feeding explains a count that is not going down, so it
// counts as movement: an engine is only holding what it has
// if it neither transmits nor is given anything
moved = now;
await send(next, cancellation).ConfigureAwait(false);
Changed?.Invoke(this, EventArgs.Empty);
continue;
}
if (inEngine == 0 && left <= 0 && !IsSending)
bool behind = left > Slack && now - moved < HoldingPatience;
// the feeder owes at most `Ahead` characters at any moment,
// so a start, a brake letting go or an empty pane does not turn
// into a burst that puts the whole message beyond reach
DateTime earliest = now - (SymbolTime * (Ahead - 1));
if (due < earliest)
{
due = earliest;
}
await handing.WaitAsync(cancellation).ConfigureAwait(false);
try
{
while (!behind && now >= due && Take(now) is { } next)
{
due += SymbolTime * Symbols(next, ref feedShift);
await send(next, cancellation).ConfigureAwait(false);
Changed?.Invoke(this, EventArgs.Empty);
}
}
finally
{
handing.Release();
}
if (NothingLeftToGive())
{
Given?.Invoke(this, EventArgs.Empty);
}
// the engine is still holding what it has not transmitted, so
// the message is not over and the last characters of it have
// not been marked as gone out yet
if (left <= 0 && Outstanding == 0 && now >= due && AllAired())
{
Drained?.Invoke(this, EventArgs.Empty);
return;
}
await Task.Delay(Tick, cancellation).ConfigureAwait(false);
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
}
finally
{
lock (gate)
{
idle = true;
}
}
}
private TimeSpan Tick
/// Everything waiting has gone out and the engine has transmitted it, or
/// the gate has closed on what is left. `Aired` says so. False when text
/// arrived while the feeder was deciding, which is what keeps a message
/// added at the last moment from being stranded.
private bool AllAired()
{
lock (gate)
{
if (open && pending.Length > 0)
{
return false;
}
idle = true;
}
Aired?.Invoke(this, EventArgs.Empty);
return true;
}
/// True the first time there is nothing left to hand to the engine, and
/// false again once more text arrives.
private bool NothingLeftToGive()
{
lock (gate)
{
if (given || (open && pending.Length > 0))
{
return false;
}
given = true;
return true;
}
}
/// One character the engine held has gone out, on the clock. False when it
/// is still transmitting it or holds nothing.
///
/// The character that goes out is the first one that has not, which is
/// `sent[aired]`, and what it costs says when the one after it is due.
/// What the engine has transmitted, from the engine itself. Its count is in
/// symbols and falls as they go out, so what it drops between two answers
/// is what went on the air between them. That is the air's own rate, and
/// nothing here has to know what a character costs to follow it: the
/// symbols are spent on the characters at the front of what has not gone
/// out, at whatever `Symbols` says they cost.
///
/// A count of 0 is the end of it: the engine holds nothing, so everything
/// it was given has gone out, whatever the symbols added up to. That is
/// what makes a wrong guess about the shift correct itself every message
/// rather than accumulating.
///
/// The count reads 0 for the first 150 ms after a push, so a 0 that new is
/// passed over: it is the answer trailing what the engine was given, not an
/// engine that has transmitted it.
private bool WentOutByCount(int symbols, DateTime now)
{
lock (gate)
{
if (symbols < 0 || (symbols == 0 && now - gaveAt < CountLag))
{
return false;
}
if (lastCount > symbols)
{
budget += lastCount - symbols;
}
lastCount = symbols;
bool moved = false;
while (aired < sent.Length && outstanding > 0 && budget >= NextSymbols())
{
budget -= NextSymbols();
OneOut();
moved = true;
}
if (symbols == 0)
{
budget = 0;
while (aired < sent.Length && outstanding > 0)
{
OneOut();
moved = true;
}
}
return moved;
}
}
/// One character off the front of what has not gone out. The caller holds
/// `gate`.
private void OneOut()
{
bool figures = airShift;
Symbols(sent[aired], ref figures);
airShift = figures;
aired++;
outstanding--;
}
/// What the engine has transmitted, on the clock, for an engine that does
/// not count.
private bool WentOutByClock(DateTime now)
{
bool moved = false;
while (WentOut(now))
{
moved = true;
}
return moved;
}
private bool WentOut(DateTime now)
{
lock (gate)
{
if (outstanding <= 0 || now < nextOut || aired >= sent.Length)
{
return false;
}
bool figures = airShift;
Symbols(sent[aired], ref figures);
airShift = figures;
outstanding--;
aired++;
nextOut += SymbolTime * NextSymbols();
return true;
}
}
/// Marks everything up to `mark` as gone out, whatever the clock had
/// reached. True when that moved. The caller holds `gate`.
private bool AirTo(int mark, DateTime now)
{
if (mark <= aired)
{
return false;
}
while (aired < mark && aired < sent.Length)
{
bool figures = airShift;
Symbols(sent[aired], ref figures);
airShift = figures;
aired++;
outstanding = Math.Max(0, outstanding - 1);
}
nextOut = now + SymbolTime * NextSymbols();
return true;
}
/// How many symbols the character now at the front of what has not gone out
/// takes. The caller holds `gate`.
private int NextSymbols()
{
bool figures = airShift;
return aired < sent.Length ? Symbols(sent[aired], ref figures) : 1;
}
/// `count` more characters are in the engine as of `now`. The caller holds
/// `gate`: what the engine holds and what is still to go are one fact and
/// are written together.
private void Gave(int count, DateTime now)
{
if (count <= 0)
{
return;
}
if (outstanding == 0)
{
nextOut = now + (SymbolTime * NextSymbols());
}
outstanding += count;
// the count reads 0 for the first 150 ms after a push, so a 0 from here
// on is the answer not having caught up rather than an empty engine.
// What it has already been seen to drop still stands: those symbols
// went out whatever is given after them.
gaveAt = now;
}
/// How many characters the engine has been given that have not gone out
/// yet, counted on the clock rather than on the engine's own answer.
public int Outstanding
{
get
{
TimeSpan quarter = CharacterTime / 4;
return quarter < LongestTick ? quarter : LongestTick;
lock (gate)
{
return outstanding;
}
}
}
@@ -360,29 +772,60 @@ public sealed class TypeAhead : IDisposable
{
Counts = left >= 0;
Volatile.Write(ref counted, left);
Interlocked.Increment(ref answers);
}
/// How many symbols `character` takes, and the shift it leaves the engine
/// in. A letter sent while the engine is in figures costs a shift symbol
/// and the character, and the same the other way.
///
/// This is why the clock alone ran ahead of the air. It paced one character
/// every symbol time, and a callsign with a digit in it takes more than
/// that: 26 characters of one CQ went out in 29 symbols, one part in nine
/// slower than the clock thought.
///
/// A space is taken to put the engine back in letters, which is
/// unshift-on-space. It is a setting — `TXUOS` in MMTTY's `UserPara.ini`,
/// per profile, and a button on its own display that the operator can press
/// mid-contest — so this cannot be read once and believed. It is assumed on
/// because MMTTY's help says that is the usual setting, and because being
/// wrong that way charges a symbol too many and leaves the pane behind the
/// air rather than in front of it. `AllowedOutstanding` is what corrects
/// the rest.
private static int Symbols(char character, ref bool figures)
{
if (character is ' ' or '\r' or '\n')
{
figures = false;
return 1;
}
bool wants = !char.IsAsciiLetter(character);
if (wants == figures)
{
return 1;
}
figures = wants;
return 2;
}
/// The next character to send, or null when there is none to send now:
/// nothing is waiting, or what is waiting is behind the cursor.
private char? Take()
/// nothing is waiting, or the gate is shut.
private char? Take(DateTime now)
{
lock (gate)
{
if (pending.Length == 0 || cursor == 0)
if (!open || pending.Length == 0)
{
return null;
}
char next = pending[0];
pending.Remove(0, 1);
if (cursor != NoCursor)
{
cursor--;
}
sent.Append(next);
Gave(1, now);
if (sent.Length > KeptSent)
{
sent.Remove(0, sent.Length - KeptSent);
int dropped = sent.Length - KeptSent;
sent.Remove(0, dropped);
aired = Math.Max(0, aired - dropped);
}
return next;
}
+81 -2
View File
@@ -12,17 +12,28 @@ public sealed class WineBridgeChannel : BridgeChannel
private readonly string wine;
private readonly string bridgePath;
private readonly string? prefix;
private readonly string? logFolder;
private readonly Queue<string> lastErrors = new();
private readonly SemaphoreSlim writing = new(1, 1);
private readonly Lock writingLog = new();
private Process? bridge;
private StreamWriter? log;
private DateTime started;
private int lastCount = int.MinValue;
/// `prefix` is the WINEPREFIX to run in. Left null, Wine uses its default,
/// which is what a station with one prefix wants.
public WineBridgeChannel(string bridgePath, string? prefix = null, string wine = "wine")
/// which is what a station with one prefix wants. `logFolder`, when it is
/// given, is where the protocol log for this run is written.
public WineBridgeChannel(
string bridgePath,
string? prefix = null,
string wine = "wine",
string? logFolder = null)
{
this.bridgePath = bridgePath;
this.prefix = prefix;
this.wine = wine;
this.logFolder = logFolder;
}
public event EventHandler<string>? LineReceived;
@@ -31,6 +42,7 @@ public sealed class WineBridgeChannel : BridgeChannel
public Task StartAsync(CancellationToken cancellation = default)
{
OpenLog();
bridge = Start(bridgePath);
_ = ReadOutputAsync(bridge);
_ = ReadErrorsAsync(bridge);
@@ -46,6 +58,7 @@ public sealed class WineBridgeChannel : BridgeChannel
await running.StandardInput.WriteLineAsync(line.AsMemory(), cancellation)
.ConfigureAwait(false);
await running.StandardInput.FlushAsync(cancellation).ConfigureAwait(false);
Log(">", line);
}
finally
{
@@ -84,6 +97,71 @@ public sealed class WineBridgeChannel : BridgeChannel
}
bridge?.Dispose();
writing.Dispose();
lock (writingLog)
{
log?.Dispose();
log = null;
}
}
/// One file per run of the engine, named for when it started. It is the
/// whole protocol with a millisecond stamp on every line, which is what
/// says who keyed the transmitter and when.
private void OpenLog()
{
if (logFolder is null)
{
return;
}
try
{
Directory.CreateDirectory(logFolder);
started = DateTime.Now;
log = new StreamWriter(
Path.Combine(logFolder, $"digital-{started:yyyyMMdd-HHmmss}.log"),
append: false)
{
AutoFlush = true,
};
}
catch (IOException)
{
// a log that cannot be written stops nothing
log = null;
}
catch (UnauthorizedAccessException)
{
log = null;
}
}
/// The buffer question goes out every 50 ms and is answered just as often,
/// which would bury everything else. The question is left out and the
/// answer is written only when the count has changed.
private void Log(string direction, string line)
{
if (log is null)
{
return;
}
(string verb, string[] fields) = BridgeLine.Read(line);
if (verb == "buffer")
{
if (direction == ">" || fields.Length == 0)
{
return;
}
if (!int.TryParse(fields[0], out int count) || count == lastCount)
{
return;
}
lastCount = count;
}
lock (writingLog)
{
log?.WriteLine(
$"{(DateTime.Now - started).TotalMilliseconds,9:0} ms {direction} {line.Replace('\t', ' ')}");
}
}
private Process Start(string program, params string[] arguments)
@@ -113,6 +191,7 @@ public sealed class WineBridgeChannel : BridgeChannel
{
while (await running.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line)
{
Log("<", line);
LineReceived?.Invoke(this, line);
}
await running.WaitForExitAsync().ConfigureAwait(false);
+8 -1
View File
@@ -10,6 +10,12 @@ namespace Nonemm.Session;
/// N1MM's rule, which this follows: an action macro runs before the message is
/// sent, unless it stands after `{END}`, and then it runs when the message has
/// finished. Text after `{END}` is dropped, because the message is over.
///
/// `{RX}` is the exception. It drops the transmitter, which cannot happen
/// before the message it stands in has gone out, so it always runs with the
/// actions that follow the message. N1MM does the same: it takes `{RX}` out of
/// the text wherever it stands, sends the text, and stops the transmitter
/// after it.
public sealed record MessagePlan(
IReadOnlyList<MessageAction> Before,
string Text,
@@ -45,7 +51,8 @@ public sealed record MessagePlan(
}
else if (Action(name) is { } action)
{
(ended ? after : before).Add(action);
(ended || action.Command == MessageCommand.ReturnToReceive ? after : before)
.Add(action);
}
continue;
}