Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ What changed in each release. The section for a version is lifted into that vers

Newest first. Add a section before tagging.

## 0.7.0 - 2026-08-21

- **"Power-cycle the radio now" is a prompt, not a line in the log.** A read or a write puts it on the screen where it cannot be missed, and it takes itself back down the moment the radio answers - the normal case needs no keystroke at all. Cancel, or Esc, abandons the operation, which is the way out when the radio is not going to answer rather than sitting through the full 90-second wait.
- **Read and write show progress.** A bar and a percentage on the radio bar, from the library rather than guessed at: sections for a read, records for a write (`writing 52% (88/168)`).
- Cancelling a write is only offered up to the point where the write block opens. Past that the codeplug is being modified, and stopping half way would leave it open and partly applied, so a started write always runs to its commit.
- **The port box accepts typing again.** It is a dropdown of detected ports, and it shipped read-only, so on a machine where the radio's port does not enumerate - which a plain USB-serial cable often does not - there was no way to name one and the interactive mode could not be used at all.
- Progress redraws are throttled to a few a second. See below for why that matters more than it sounds.

**On typing being slow over SSH**, which is what prompted this release: it is real, it is measurable, and it is not something this tool can fix. Terminal.Gui repaints the entire screen for every character typed into a text box. Measured against a minimal Terminal.Gui app - one window, one text field, nothing else - so it is not something about this UI:

| terminal | per typed character |
|---|---|
| 80x24 | 13 KB |
| 100x30 | 22 KB |
| 120x40 | 37 KB |
| 200x50 | 82 KB |

That is ~7-8 bytes per cell on screen, every keystroke, and 2.4.18-develop.31 behaves identically. Locally it is invisible. On a maximised terminal over SSH it is the second or two per character that typing a frequency actually felt like. Until it is fixed upstream, three things help: a smaller terminal window while editing (80x24 is six times cheaper than 200x50), `patch <port> ch0.rxfreq 144.812500` from the command line instead of the editor, or running the tool on the machine the radio is plugged into rather than across a link.

## 0.6.1 - 2026-08-21

- **The interactive mode stops talking to the terminal when nobody is using it.** Terminal.Gui runs its main loop 25 times a second whether or not anything has changed, and rewrites cursor state every time round: sitting there with nothing happening, the tool was emitting ~315 bytes a second in 25 separate writes, for as long as it was open. The loop now steps down after ten seconds untouched and again after a minute. Measured idle output falls from 315 bytes/sec to 128 after a short pause and to 54 after a long one.
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,24 @@ Run it with no arguments and you get a screen instead of a verb: pick a port, re
╰──────────────────────────────────────────────────────────────────────────────────────────╯
```

`F6` moves between panels and `Tab` moves within one; the panel holding the keyboard lights its border. `F5` reads the radio (power-cycle it when the log says so), `F3` edits the selected channel, `F7` adds one, `F8` deletes one, `F2` writes back, `F10` quits. The PDN preset is staged and applied when you write, so choosing one changes nothing until you commit. A write always snapshots the pre-change codeplug to a `tait-codeplug-backup-<timestamp>.m8p` first.
`F6` moves between panels and `Tab` moves within one; the panel holding the keyboard lights its border. `F5` reads the radio (it prompts you to power-cycle it), `F3` edits the selected channel, `F7` adds one, `F8` deletes one, `F2` writes back, `F10` quits. The PDN preset is staged and applied when you write, so choosing one changes nothing until you commit. A write always snapshots the pre-change codeplug to a `tait-codeplug-backup-<timestamp>.m8p` first.

The radio work runs off the UI thread, so the screen stays live through the ~25s read and the 90s the connect will wait for your power-cycle.

Left alone, it goes quiet: the main loop steps down after ten seconds untouched and again after a minute, so an editor left open over SSH is not writing to your terminal 25 times a second all afternoon. Typing is unaffected; the one key that wakes it after a long pause can take up to a quarter of a second to register, and everything after it is normal.

`F5` and `F2` put "power-cycle the radio now" on the screen rather than in the log, and take it down again by themselves once the radio answers. Cancel or Esc abandons the operation instead of waiting out the full 90 seconds. Both show a progress bar while they run.

### Typing feels slow over SSH

It is, and it is worth knowing why before you go looking for a fault at your end. Terminal.Gui repaints the whole screen for every character typed into a text box - about 7-8 bytes per cell on screen, so 22 KB on a 100x30 terminal and 82 KB at 200x50, per keystroke. A minimal Terminal.Gui app does the same, so it is the library rather than this tool, and there is nothing to configure around it.

Locally you will not notice. Across an SSH link to a maximised terminal it is a second or two per character. What helps:

- Make the terminal window smaller while you are editing: 80x24 costs a sixth of what 200x50 does.
- Skip the editor for a single value: `tait-codeplug patch /dev/ttyUSB0 ch0.rxfreq 144.812500` does a read-modify-write with no typing in a UI at all.
- Run the tool on the machine the radio is plugged into, rather than across the link.

Colours are true-colour: a dark slate palette, green for read, amber for write (it is the one that changes your radio), red for errors. Terminal.Gui maps them down on a 16- or 256-colour terminal, so it stays legible on a plain console.

To try the editor without a radio on the bench, open a saved codeplug: `tait-codeplug tui radio.m8p`.
Expand Down
212 changes: 206 additions & 6 deletions src/M0LTE.Tait.Codeplug.Cli/Tui.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ internal static class Tui
private static Button _readButton = null!;
private static Button _writeButton = null!;
private static Label _detectedLabel = null!;
private static ProgressBar _progress = null!;
private static Label _progressLabel = null!;

/// <summary>Cancels the radio operation in flight, from the power-cycle prompt's Cancel button.</summary>
private static CancellationTokenSource? _radioCancel;

/// <summary>The power-cycle prompt while it is up, so the read/write can dismiss it itself.</summary>
private static Dialog? _powerCyclePrompt;

private static bool _radioLatched;

/// <summary>Set once the operation has ended of its own accord, so dismissing the prompt after
/// that is not mistaken for cancelling.</summary>
private static bool _radioFinished;
private static TuiProgressThrottle _progressThrottle = new();
private static Window _window = null!;
private static IApplication _app = null!;

Expand Down Expand Up @@ -136,7 +151,10 @@ private static Window Build()

// A dropdown of what is actually plugged in, rather than a box you have to know what to type
// into. It still derives from a text field, so a port that did not enumerate can be typed.
_portField = new DropDownList { X = 7, Y = 0, Width = 26 };
// ReadOnly is a DropDownList's default, and it makes the box a picker you cannot type into -
// so on a machine where the radio's port does not enumerate (a plain USB-serial cable often
// does not), there was no way to name one. Editable makes it the combo box it looks like.
_portField = new DropDownList { X = 7, Y = 0, Width = 26, ReadOnly = false };
RefreshPorts();

var rescanButton = new Button { Text = "Re_scan", X = 35, Y = 0 };
Expand All @@ -158,6 +176,12 @@ private static Window Build()

_statusLabel = new Label { X = 42, Y = 2, Text = "no codeplug loaded" };

// Idle, these are hidden and the status label has the row to itself: an empty bar sitting
// there permanently reads as broken. They sit on the button row rather than the one above it,
// which carries the drop shadows of the port box and the Rescan button.
_progress = new ProgressBar { X = 42, Y = 2, Width = 26, Height = 1, Visible = false, Fraction = 0f };
_progressLabel = new Label { X = 70, Y = 2, Text = string.Empty, Visible = false };

TuiTheme.Panelise(radio);
TuiTheme.Body(portLabel);
TuiTheme.Input(_portField);
Expand All @@ -166,7 +190,9 @@ private static Window Build()
TuiTheme.Action(_readButton, TuiAccent.Read);
TuiTheme.Action(_writeButton, TuiAccent.Write);
TuiTheme.Status(_statusLabel, loaded: false);
radio.Add(portLabel, _portField, rescanButton, detected, _readButton, _writeButton, _statusLabel);
TuiTheme.Secondary(_progressLabel);
radio.Add(portLabel, _portField, rescanButton, detected, _readButton, _writeButton, _statusLabel,
_progress, _progressLabel);

// --- channels (left) ----------------------------------------------------------------------
var channels = new FrameView
Expand Down Expand Up @@ -379,20 +405,25 @@ private static void StartRead()
}

SetBusy(true, $"reading {port}...");
Log($"opening {port} at 19200 8N1 - POWER-CYCLE THE RADIO NOW to latch programming mode.");
Log($"opening {port} at 19200 8N1 - power-cycle the radio to latch programming mode.");

CancellationToken token = BeginRadioOperation();

RunOffThread(
() =>
{
using var programmer = new TaitProgrammer(new SerialPortLine(port), HardwareOptions());
return programmer.ReadImage();
programmer.Progress += OnProgress;
return programmer.ReadImage(cancellationToken: token);
},
image =>
{
_image = image;
Log($"read {image.Records.Count} records, all checksums verified.");
LoadFields();
});

PromptToPowerCycle("Reading the radio");
}

private static void StartWrite()
Expand Down Expand Up @@ -439,6 +470,7 @@ private static void StartWrite()
CodeplugFields fields = _fields;

SetBusy(true, $"writing {port}...");
CancellationToken token = BeginRadioOperation();

RunOffThread(
() =>
Expand All @@ -456,7 +488,8 @@ private static void StartWrite()
File.WriteAllText(backup, image.ToM8p());

using var programmer = new TaitProgrammer(new SerialPortLine(port), HardwareOptions());
int written = programmer.WriteImage(image);
programmer.Progress += OnProgress;
int written = programmer.WriteImage(image, token);
return (backup, written);
},
result =>
Expand All @@ -470,6 +503,149 @@ private static void StartWrite()
Log($"wrote {result.written} records. Power-cycle and re-read to verify - "
+ "read-back in the same session is unreliable after a write.");
});

PromptToPowerCycle("Writing to the radio");
}

// --- the power-cycle prompt and progress ------------------------------------------------------

/// <summary>Set up cancellation and progress state for a read or a write, and return the token the
/// worker should carry.</summary>
private static CancellationToken BeginRadioOperation()
{
_radioCancel?.Dispose();
_radioCancel = new CancellationTokenSource();
_radioLatched = false;
_radioFinished = false;
_progressThrottle = new TuiProgressThrottle();
ShowProgress(null, string.Empty);
return _radioCancel.Token;
}

/// <summary>
/// The one instruction the operator has to act on, in front of them rather than as a line in the
/// log they may not be looking at. It takes itself down the moment the radio answers, so the
/// normal case needs no keystroke at all; Cancel (or Esc) abandons the operation, which is the
/// escape route when the radio is not going to answer.
/// </summary>
private static void PromptToPowerCycle(string title)
{
if (_radioLatched)
{
return; // the radio was already listening; no need to ask for anything
}

var dialog = new Dialog
{
Title = title,
Width = 62,
Height = 15,
BorderStyle = LineStyle.Rounded,
};

var instruction = new Label
{
X = Pos.Center(),
Y = 1,
Text = "POWER-CYCLE THE RADIO NOW",
};

var detail = new Label
{
X = 2,
Y = 3,
Text = "Switch it off and back on. The radio latches\nprogramming mode as it boots, so the tool has to be\nlistening before that happens - which it now is.",
};

var waiting = new Label
{
X = 2,
Y = 7,
Text = "Waiting up to 90 seconds. This box closes itself as\nsoon as the radio answers - no keystroke needed.",
};

var cancel = new Button { Text = "Cancel", IsDefault = true };
cancel.Accepting += (_, e) =>
{
e.Handled = true;
_app.RequestStop(dialog);
};

TuiTheme.Alert(instruction);
TuiTheme.Secondary(waiting);
dialog.AddButton(cancel);
dialog.Add(instruction, detail, waiting);

_powerCyclePrompt = dialog;
try
{
_app.Run(dialog);
}
finally
{
_powerCyclePrompt = null;
dialog.Dispose();
}

// However the box went away - the Cancel button, Esc, anything else - if the radio has not
// answered and the operation has not ended on its own, the operator is done waiting. Without
// this, Esc would take the prompt off the screen and leave the read running invisibly for the
// rest of its 90-second wait.
if (!_radioLatched && !_radioFinished)
{
_radioCancel?.Cancel();
Log("cancelled - the radio was not answering.");
}
}

/// <summary>Progress arrives on the worker thread; everything it touches lives on the UI thread.</summary>
private static void OnProgress(object? sender, ProgrammerProgress p) => _app.Invoke(() => ApplyProgress(p));

private static void ApplyProgress(ProgrammerProgress p)
{
if (p.Phase == ProgrammerPhase.Connected)
{
_radioLatched = true;
Log("radio latched into programming mode.");
if (_powerCyclePrompt is { } prompt)
{
_app.RequestStop(prompt);
}

return;
}

bool isFinal = p.Phase is ProgrammerPhase.Committed || (p.Total > 0 && p.Done >= p.Total);
if (!_progressThrottle.ShouldDraw(p.Fraction, isFinal, DateTime.UtcNow))
{
return;
}

string verb = p.Phase switch
{
ProgrammerPhase.Reading => "reading",
ProgrammerPhase.PreparingWrite => "preparing",
ProgrammerPhase.Writing => "writing",
ProgrammerPhase.Committed => "committed",
_ => "working",
};

// Compact on purpose: this shares a row with the two buttons, and a caption that runs off the
// panel is worse than one that says less.
ShowProgress(p.Fraction, p.Fraction is { } f
? $"{verb} {f * 100:F0}% ({p.Done}/{p.Total})"
: $"{verb} - {p.What}");
}

/// <summary>Show the bar and its caption, or hide both when there is nothing running.</summary>
private static void ShowProgress(double? fraction, string caption)
{
bool show = fraction is not null || caption.Length > 0;
_progress.Visible = show;
_progressLabel.Visible = show;
_statusLabel.Visible = !show; // they share the row: the bar says more while it is up
_progress.Fraction = (float)(fraction ?? 0);
_progressLabel.Text = caption;
}

private static ProgrammerOptions HardwareOptions() => new()
Expand All @@ -488,15 +664,27 @@ private static void RunOffThread<T>(Func<T> work, Action<T> onSuccess)
T result = work();
_app.Invoke(() =>
{
FinishRadioOperation();
onSuccess(result);
SetBusy(false, StatusText());
});
}
catch (OperationCanceledException)
{
// Cancelling is a decision, not a fault: no dialog, and the prompt is already gone.
_app.Invoke(() =>
{
FinishRadioOperation();
SetBusy(false, StatusText());
});
}
catch (Exception ex) when (ex is IOException or TimeoutException or InvalidOperationException
or ArgumentException or UnauthorizedAccessException or FormatException)
or ArgumentException or UnauthorizedAccessException or FormatException
or NotSupportedException)
{
_app.Invoke(() =>
{
FinishRadioOperation();
Log($"error: {ex.Message}");
SetBusy(false, StatusText());
Error("Radio error", ex.Message);
Expand All @@ -505,6 +693,18 @@ private static void RunOffThread<T>(Func<T> work, Action<T> onSuccess)
});
}

/// <summary>Take the prompt and the bar down, whatever the operation's outcome was.</summary>
private static void FinishRadioOperation()
{
_radioFinished = true;
if (_powerCyclePrompt is { } prompt)
{
_app.RequestStop(prompt);
}

ShowProgress(null, string.Empty);
}

// --- codeplug state ---------------------------------------------------------------------------

private static void LoadFields()
Expand Down
Loading