From 1728b4aad63d0698f7f463730cb642a792fcbb7c Mon Sep 17 00:00:00 2001 From: Tom M0LTE Date: Fri, 21 Aug 2026 12:44:21 +0000 Subject: [PATCH] feat(tui): prompt to power-cycle, show read/write progress, and fix the port box Three things from the bench, plus what the typing lag turned out to be. **The power-cycle instruction is a prompt now.** It was a line in the log, which is the wrong place for the one thing the operator has to go and do to the hardware. A read or a write puts it on the screen, and takes it down again by itself the moment the radio answers - the normal case needs no keystroke at all. Cancel, or Esc, abandons the operation instead of sitting through the remaining 90-second wait. Anything that dismisses the box counts as cancelling, so Esc cannot leave a read running invisibly. **Read and write show progress**, from the library rather than guessed at: TaitProgrammer now raises a ProgrammerProgress with a phase, a count and a total - sections for a read, records for a write - and takes a CancellationToken. Cancelling a write is only honoured up to the point where the write block opens. Past that the radio's codeplug is being modified and stopping half way would leave the block open and the codeplug partly applied, which is worse than finishing, so a started write always runs to its commit. Two tests hold both halves of that. Progress redraws are throttled: a write is over a thousand records, and with Terminal.Gui repainting the whole screen for any change (see below), an unthrottled bar would push a thousand full repaints down an SSH link and become the slowest thing in the write. **The port box would not accept typing.** DropDownList defaults to ReadOnly, which makes it a picker rather than the combo box it looks like, so on a machine where the radio's port does not enumerate there was no way to name one and the interactive mode could not be used at all. The README has claimed since 0.6.0 that a port which did not enumerate can be typed in; now it can. **On the typing lag itself**: it is Terminal.Gui, and it is not something this app can fix. Every character typed into a text box repaints the entire screen, measured at ~7-8 bytes per cell: 80x24 ( 1920 cells) 13,428 bytes per typed character 100x30 ( 3000 cells) 21,826 120x40 ( 4800 cells) 36,885 200x50 (10000 cells) 81,658 A minimal Terminal.Gui app - one window, one text field, nothing else - does the same, so it is not this UI; 2.4.18-develop.31 does the same, so it is not a version bump away. On a maximised terminal over SSH, 82 KB a character is the second or two per keystroke that typing a frequency felt like. Caret moves and backspace are ~135 bytes, which is why only insertion feels slow. Tried and rejected: disabling the dialog's shadow, the superview line canvas, the field's autocomplete and its suggestion generator; an in-window overlay instead of a modal; a hand-written single-line field that only marks itself dirty (any redraw at all costs a full frame, so it bought nothing and lost selection and clipboard). The DOTNET driver is cheaper than the ANSI one (15 KB against 22 KB) but still a whole-screen repaint. Documented in the README with the three things that do help: a smaller terminal while editing, `patch ch0.rxfreq ` instead of the editor, or running the tool on the machine the radio is plugged into. Verified end to end against a fake TM8100 on a pty: the prompt appears, dismisses itself when the radio answers, and Cancel and Esc both abandon the read cleanly; a 265-record read and a 168-record write both complete with the bar moving and clearing afterwards. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FkDFej82QnbjMJYFcwYyAZ --- CHANGELOG.md | 19 ++ README.md | 14 +- src/M0LTE.Tait.Codeplug.Cli/Tui.cs | 212 +++++++++++++++++- .../TuiProgressThrottle.cs | 54 +++++ src/M0LTE.Tait.Codeplug.Cli/TuiTheme.cs | 12 + src/M0LTE.Tait.Codeplug/ProgrammerProgress.cs | 38 ++++ src/M0LTE.Tait.Codeplug/TaitProgrammer.cs | 50 ++++- .../ProgrammerProgressTests.cs | 190 ++++++++++++++++ .../TuiProgressThrottleTests.cs | 76 +++++++ 9 files changed, 648 insertions(+), 17 deletions(-) create mode 100644 src/M0LTE.Tait.Codeplug.Cli/TuiProgressThrottle.cs create mode 100644 src/M0LTE.Tait.Codeplug/ProgrammerProgress.cs create mode 100644 tests/M0LTE.Tait.Codeplug.Tests/ProgrammerProgressTests.cs create mode 100644 tests/M0LTE.Tait.Codeplug.Tests/TuiProgressThrottleTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a642e2..131c5e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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. diff --git a/README.md b/README.md index 8eb1a4d..8cebba7 100644 --- a/README.md +++ b/README.md @@ -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-.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-.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`. diff --git a/src/M0LTE.Tait.Codeplug.Cli/Tui.cs b/src/M0LTE.Tait.Codeplug.Cli/Tui.cs index 2f95419..f653032 100644 --- a/src/M0LTE.Tait.Codeplug.Cli/Tui.cs +++ b/src/M0LTE.Tait.Codeplug.Cli/Tui.cs @@ -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!; + + /// Cancels the radio operation in flight, from the power-cycle prompt's Cancel button. + private static CancellationTokenSource? _radioCancel; + + /// The power-cycle prompt while it is up, so the read/write can dismiss it itself. + private static Dialog? _powerCyclePrompt; + + private static bool _radioLatched; + + /// Set once the operation has ended of its own accord, so dismissing the prompt after + /// that is not mistaken for cancelling. + private static bool _radioFinished; + private static TuiProgressThrottle _progressThrottle = new(); private static Window _window = null!; private static IApplication _app = null!; @@ -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 }; @@ -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); @@ -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 @@ -379,13 +405,16 @@ 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 => { @@ -393,6 +422,8 @@ private static void StartRead() Log($"read {image.Records.Count} records, all checksums verified."); LoadFields(); }); + + PromptToPowerCycle("Reading the radio"); } private static void StartWrite() @@ -439,6 +470,7 @@ private static void StartWrite() CodeplugFields fields = _fields; SetBusy(true, $"writing {port}..."); + CancellationToken token = BeginRadioOperation(); RunOffThread( () => @@ -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 => @@ -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 ------------------------------------------------------ + + /// Set up cancellation and progress state for a read or a write, and return the token the + /// worker should carry. + private static CancellationToken BeginRadioOperation() + { + _radioCancel?.Dispose(); + _radioCancel = new CancellationTokenSource(); + _radioLatched = false; + _radioFinished = false; + _progressThrottle = new TuiProgressThrottle(); + ShowProgress(null, string.Empty); + return _radioCancel.Token; + } + + /// + /// 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. + /// + 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."); + } + } + + /// Progress arrives on the worker thread; everything it touches lives on the UI thread. + 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}"); + } + + /// Show the bar and its caption, or hide both when there is nothing running. + 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() @@ -488,15 +664,27 @@ private static void RunOffThread(Func work, Action 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); @@ -505,6 +693,18 @@ private static void RunOffThread(Func work, Action onSuccess) }); } + /// Take the prompt and the bar down, whatever the operation's outcome was. + private static void FinishRadioOperation() + { + _radioFinished = true; + if (_powerCyclePrompt is { } prompt) + { + _app.RequestStop(prompt); + } + + ShowProgress(null, string.Empty); + } + // --- codeplug state --------------------------------------------------------------------------- private static void LoadFields() diff --git a/src/M0LTE.Tait.Codeplug.Cli/TuiProgressThrottle.cs b/src/M0LTE.Tait.Codeplug.Cli/TuiProgressThrottle.cs new file mode 100644 index 0000000..d673d2d --- /dev/null +++ b/src/M0LTE.Tait.Codeplug.Cli/TuiProgressThrottle.cs @@ -0,0 +1,54 @@ +namespace M0LTE.Tait.Codeplug.Cli; + +/// +/// Decides whether a progress update is worth putting on screen. +/// +/// A write is over a thousand records and reports one update each, so redrawing on every one would be +/// a thousand redraws. That matters more than it sounds: Terminal.Gui repaints the whole screen for +/// any change, ~22 KB of escape sequences at 100x30, so an unthrottled progress bar would push a +/// thousand full repaints down what may well be an SSH link, and the progress bar itself would become +/// the slowest thing in the write. +/// +/// So an update is drawn only when the percentage actually changes, and at most a few times a second. +/// The final update is always drawn, so the bar never stops short of the end. +/// +internal sealed class TuiProgressThrottle(TimeSpan minimumInterval) +{ + private int _lastPercent = -1; + private DateTime _lastDrawUtc = DateTime.MinValue; + + /// Four a second: fast enough to look live, slow enough to stay out of the way. + internal TuiProgressThrottle() + : this(TimeSpan.FromMilliseconds(250)) + { + } + + /// + /// Whether to redraw for this update. is passed in rather than read from + /// the clock so this is testable. + /// + internal bool ShouldDraw(double? fraction, bool isFinal, DateTime nowUtc) + { + if (isFinal) + { + _lastPercent = fraction is null ? -1 : (int)(fraction.Value * 100); + _lastDrawUtc = nowUtc; + return true; + } + + int percent = fraction is null ? -1 : (int)(fraction.Value * 100); + if (percent == _lastPercent) + { + return false; + } + + if (nowUtc - _lastDrawUtc < minimumInterval) + { + return false; + } + + _lastPercent = percent; + _lastDrawUtc = nowUtc; + return true; + } +} diff --git a/src/M0LTE.Tait.Codeplug.Cli/TuiTheme.cs b/src/M0LTE.Tait.Codeplug.Cli/TuiTheme.cs index f45e63d..1a2b956 100644 --- a/src/M0LTE.Tait.Codeplug.Cli/TuiTheme.cs +++ b/src/M0LTE.Tait.Codeplug.Cli/TuiTheme.cs @@ -184,6 +184,18 @@ internal static void Body(View view) => view.SetScheme(new Scheme Disabled = new Attribute(Dim, Panel), }); + /// The one line in a prompt that the operator has to act on: amber and bold, because it + /// is an instruction to go and do something to the hardware, not a status. + internal static void Alert(View view) => view.SetScheme(new Scheme + { + Normal = new Attribute(Amber, Panel, TextStyle.Bold), + HotNormal = new Attribute(Amber, Panel, TextStyle.Bold), + Focus = new Attribute(White, SelectionBg, TextStyle.Bold), + HotFocus = new Attribute(White, SelectionBg, TextStyle.Bold), + Highlight = new Attribute(White, SelectionBg), + Disabled = new Attribute(Dim, Panel), + }); + /// Status text, tinted by what it is saying. internal static void Status(View label, bool loaded) => label.SetScheme(new Scheme { diff --git a/src/M0LTE.Tait.Codeplug/ProgrammerProgress.cs b/src/M0LTE.Tait.Codeplug/ProgrammerProgress.cs new file mode 100644 index 0000000..033c71f --- /dev/null +++ b/src/M0LTE.Tait.Codeplug/ProgrammerProgress.cs @@ -0,0 +1,38 @@ +namespace M0LTE.Tait.Codeplug; + +/// +/// What a long radio operation is doing, so a caller can show it rather than leave the operator +/// watching a frozen screen. A read is ~25 seconds and a write is comparable, both preceded by a +/// connect that can sit for up to 90 seconds waiting for the operator to power-cycle the radio. +/// +/// Which stage of the operation this is. +/// Units finished: sections read, or records written. +/// Units expected, or 0 when the total is not known yet. +/// A short human-readable note, e.g. "section 05" or "record 412 of 1103". +public sealed record ProgrammerProgress(ProgrammerPhase Phase, int Done, int Total, string What) +{ + /// Fraction complete in 0..1, or null when is not yet known. + public double? Fraction => Total > 0 ? Math.Clamp((double)Done / Total, 0, 1) : null; +} + +/// The stages a caller may want to show differently. +public enum ProgrammerPhase +{ + /// Probing for the radio's boot banner. This is where the operator must power-cycle. + WaitingForRadio, + + /// The radio answered and programming mode is latched. + Connected, + + /// Reading sections. + Reading, + + /// The preamble reads and guards the CPS performs before a write block. + PreparingWrite, + + /// Writing records. + Writing, + + /// The write block has been committed. + Committed, +} diff --git a/src/M0LTE.Tait.Codeplug/TaitProgrammer.cs b/src/M0LTE.Tait.Codeplug/TaitProgrammer.cs index 2bd3041..4ad3666 100644 --- a/src/M0LTE.Tait.Codeplug/TaitProgrammer.cs +++ b/src/M0LTE.Tait.Codeplug/TaitProgrammer.cs @@ -70,6 +70,13 @@ public sealed class TaitProgrammer : IDisposable private int _rxPos; private bool _connected; + /// + /// Raised as a long operation moves along, on the thread doing the work. A UI handler must + /// marshal to its own thread. Handlers should be quick: this is raised once per section read and + /// once per record written, and a full write is over a thousand records. + /// + public event EventHandler? Progress; + /// Wrap an open serial line. public TaitProgrammer(ISerialLine line, ProgrammerOptions? options = null) { @@ -78,19 +85,25 @@ public TaitProgrammer(ISerialLine line, ProgrammerOptions? options = null) } /// Enter programming mode and complete the ld/d00 handshake. Idempotent. - public void Connect() + /// Abandons the wait for the radio. The line is left to the caller + /// to dispose; nothing has been written to the radio at this point. + public void Connect(CancellationToken cancellationToken = default) { if (_connected) { return; } + Report(ProgrammerPhase.WaitingForRadio, 0, 0, "power-cycle the radio now"); + // Boot-latch: the read is triggered first and the radio powered on second, so retry the // reset probe through the boot window until the radio answers with its 'v' banner. long deadline = Environment.TickCount64 + _options.ConnectWaitMs; int attempt = 0; while (true) { + cancellationToken.ThrowIfCancellationRequested(); + try { if (_options.ProbeBauds.Count > 0) @@ -118,8 +131,12 @@ public void Connect() Transact("ld"); // login/version; radio answers {Cxx} Transact("d00"); // select database _connected = true; + Report(ProgrammerPhase.Connected, 0, 0, "programming mode latched"); } + private void Report(ProgrammerPhase phase, int done, int total, string what) + => Progress?.Invoke(this, new ProgrammerProgress(phase, done, total, what)); + /// Read the identity block plus the two status sections the CPS interrogate pulls. public TaitIdentity Interrogate() { @@ -134,16 +151,21 @@ public TaitIdentity Interrogate() /// Read the given sections and assemble a . If /// is null, the standard set observed in a full CPS read is used. - public CodeplugImage ReadImage(IReadOnlyList? sections = null) + public CodeplugImage ReadImage(IReadOnlyList? sections = null, CancellationToken cancellationToken = default) { - Connect(); + Connect(cancellationToken); sections ??= DefaultReadSections; var records = new List(); - foreach (byte s in sections) + for (int i = 0; i < sections.Count; i++) { + cancellationToken.ThrowIfCancellationRequested(); + byte s = sections[i]; + Report(ProgrammerPhase.Reading, i, sections.Count, $"section {s:X2}"); records.AddRange(ReadSection(s)); } + Report(ProgrammerPhase.Reading, sections.Count, sections.Count, "read complete"); + // The CPS requires Radio / Tier / DBVer / Build in the .m8p header, or it refuses to load // the file ("unrecognised format or invalid database version"). DBVer comes from the // codeplug itself (record 0x27), so the file matches the radio's actual database version. @@ -177,10 +199,10 @@ public IReadOnlyList ReadSection(byte section) /// a w<record> per record awaiting each prompt, e). Section 0 (the read-only /// identity) is skipped, matching the CPS. Returns the number of records written. /// - public int WriteImage(CodeplugImage image) + public int WriteImage(CodeplugImage image, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(image); - return WriteRecords(image.Records); + return WriteRecords(image.Records, cancellationToken); } /// @@ -190,10 +212,10 @@ public int WriteImage(CodeplugImage image) /// in place without rewriting the rest. Section 0 (the read-only identity) is always skipped. /// Returns the number of records written. /// - public int WriteRecords(IEnumerable records) + public int WriteRecords(IEnumerable records, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(records); - Connect(); + Connect(cancellationToken); var toWrite = records.Where(r => r.Section != 0x00).ToList(); if (toWrite.Count == 0) @@ -202,6 +224,7 @@ public int WriteRecords(IEnumerable records) } // Faithful preamble reads (harmless, and what the CPS does before a write). + Report(ProgrammerPhase.PreparingWrite, 0, toWrite.Count, "preamble and database-version guard"); ReadSection(0x00); Transact("p01"); IReadOnlyList versionRecords = ReadSection(0x27); @@ -211,15 +234,22 @@ public int WriteRecords(IEnumerable records) Transact("p01"); ReadSection(0x22); + // Last chance to abandon: once the write block is open the radio's codeplug is being + // modified, and stopping half way would leave it open and partly applied - worse than + // finishing. So the token is checked here and not again inside the block. + cancellationToken.ThrowIfCancellationRequested(); + Transact("b"); // begin Transact("i" + _options.WriteInitArg); // init/unlock - foreach (CodeplugRecord r in toWrite) + for (int i = 0; i < toWrite.Count; i++) { - Transact("w" + r.ToWireLine()); + Transact("w" + toWrite[i].ToWireLine()); + Report(ProgrammerPhase.Writing, i + 1, toWrite.Count, $"record {i + 1} of {toWrite.Count}"); } Transact("e"); // end/commit + Report(ProgrammerPhase.Committed, toWrite.Count, toWrite.Count, "write committed"); return toWrite.Count; } diff --git a/tests/M0LTE.Tait.Codeplug.Tests/ProgrammerProgressTests.cs b/tests/M0LTE.Tait.Codeplug.Tests/ProgrammerProgressTests.cs new file mode 100644 index 0000000..7dc209d --- /dev/null +++ b/tests/M0LTE.Tait.Codeplug.Tests/ProgrammerProgressTests.cs @@ -0,0 +1,190 @@ +using AwesomeAssertions; +using M0LTE.Tait.Codeplug; +using Xunit; + +namespace M0LTE.Tait.Codeplug.Tests; + +/// +/// A read is ~25 seconds and a write is comparable, both behind a connect that can sit for 90 seconds +/// waiting for the operator to power-cycle the radio. The caller needs two things from that: to know +/// when the radio has actually latched, so a "power-cycle now" prompt can take itself down, and to +/// know how far along the transfer is. These pin down both, plus the cancellation the prompt's Cancel +/// button relies on. +/// +public class ProgrammerProgressTests +{ + private static Dictionary ReadScript() => new() + { + ["^"] = "v", + ["#"] = ">", + ["ld"] = "{C05}\r>", + ["d00"] = "{C01}\r>", + ["p00"] = ">", + ["p01"] = ">", + + // The real captured section 0, so the .m8p header decode has an identity to work from. + ["r00"] = + "000010544D414231322D423130305F3032303147\r" + + "000115514D4131465F7374645F30322E31382E30302E303076\r" + + "000209303039342C303038362E\r" + + "0003040000FFAA50\r" + + "000405544D4143359D\r" + + "00050831393932353332384C\r" + + "00060830313032303030306F\r>", + }; + + /// One valid record, built with the library's own encoder so the checksum is real. + private static string OneRecord(byte section) + => new CodeplugRecord(section, 0, [0x01]).ToWireLine() + "\r>"; + + /// Answers any section read with one record, so a read walks every section. + private static string? EmptySection(string command) + => command.StartsWith('r') && command.Length == 3 + ? OneRecord(Convert.ToByte(command[1..], 16)) + : null; + + [Fact] + public void Connecting_reports_waiting_then_latched() + { + var radio = new ScriptedRadio(ReadScript(), EmptySection); + using var programmer = new TaitProgrammer(radio); + var phases = new List(); + programmer.Progress += (_, p) => phases.Add(p.Phase); + + programmer.Connect(); + + phases.Should().StartWith([ProgrammerPhase.WaitingForRadio, ProgrammerPhase.Connected], + "the prompt goes up on the first and comes down on the second"); + } + + [Fact] + public void A_read_reports_progress_that_reaches_the_end() + { + var radio = new ScriptedRadio(ReadScript(), EmptySection); + using var programmer = new TaitProgrammer(radio); + var reads = new List(); + programmer.Progress += (_, p) => + { + if (p.Phase == ProgrammerPhase.Reading) + { + reads.Add(p); + } + }; + + programmer.ReadImage(); + + reads.Should().NotBeEmpty(); + reads[0].Fraction.Should().Be(0); + reads[^1].Fraction.Should().Be(1, "a bar that stops at 96% looks like a hang"); + reads.Select(r => r.Done).Should().BeInAscendingOrder(); + } + + [Fact] + public void A_write_reports_one_step_per_record_and_then_commits() + { + var radio = new ScriptedRadio(WriteScript(), WriteFallback); + using var programmer = new TaitProgrammer(radio, AllowWrite()); + var progress = new List(); + programmer.Progress += (_, p) => progress.Add(p); + + int written = programmer.WriteRecords(TwoRecords()); + + written.Should().Be(2); + progress.Where(p => p.Phase == ProgrammerPhase.Writing).Select(p => p.Done) + .Should().Equal([1, 2]); + progress.Where(p => p.Phase == ProgrammerPhase.Writing).Select(p => p.Total) + .Should().AllBeEquivalentTo(2); + progress[^1].Phase.Should().Be(ProgrammerPhase.Committed); + } + + [Fact] + public void Cancelling_while_waiting_for_the_radio_throws_and_writes_nothing() + { + // A radio that never answers the reset probe: the case the Cancel button exists for. + var radio = new ScriptedRadio(new Dictionary(), _ => string.Empty); + using var programmer = new TaitProgrammer(radio, new ProgrammerOptions { ConnectWaitMs = 10_000 }); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Action connect = () => programmer.Connect(cts.Token); + + connect.Should().Throw(); + radio.CommandsSeen.Should().BeEmpty("cancelling before the first probe must not touch the radio"); + } + + [Fact] + public void Cancelling_a_write_before_the_block_opens_leaves_the_radio_alone() + { + var radio = new ScriptedRadio(WriteScript(), WriteFallback); + using var programmer = new TaitProgrammer(radio, AllowWrite()); + using var cts = new CancellationTokenSource(); + + // Cancel once the preamble has run, which is the last point at which stopping is safe. + programmer.Progress += (_, p) => + { + if (p.Phase == ProgrammerPhase.PreparingWrite) + { + cts.Cancel(); + } + }; + + Action write = () => programmer.WriteRecords(TwoRecords(), cts.Token); + + write.Should().Throw(); + radio.CommandsSeen.Should().NotContain("b", "the write block must never be left open"); + radio.CommandsSeen.Should().NotContain(c => c.StartsWith('w')); + } + + [Fact] + public void A_write_that_has_started_is_not_abandoned_half_way() + { + var radio = new ScriptedRadio(WriteScript(), WriteFallback); + using var programmer = new TaitProgrammer(radio, AllowWrite()); + using var cts = new CancellationTokenSource(); + + // Cancel after the first record has gone out. Stopping there would leave the block open and + // the codeplug partly written, so the write is expected to run to its commit regardless. + programmer.Progress += (_, p) => + { + if (p is { Phase: ProgrammerPhase.Writing, Done: 1 }) + { + cts.Cancel(); + } + }; + + int written = programmer.WriteRecords(TwoRecords(), cts.Token); + + written.Should().Be(2); + radio.CommandsSeen.Should().Contain("e", "an opened write block is always committed"); + } + + private static ProgrammerOptions AllowWrite() => new() { AllowUnvalidatedWrite = true }; + + private static List TwoRecords() => + [ + new(0x05, 0, [0x01]), + new(0x05, 1, [0x02]), + ]; + + private static Dictionary WriteScript() => new() + { + ["^"] = "v", + ["#"] = ">", + ["ld"] = "{C05}\r>", + ["d00"] = "{C01}\r>", + ["p00"] = ">", + ["p01"] = ">", + ["b"] = ">", + ["e"] = ">", + }; + + private static string? WriteFallback(string command) + { + if (command.StartsWith('r') && command.Length == 3) + { + return OneRecord(Convert.ToByte(command[1..], 16)); + } + + return command.StartsWith('w') || command.StartsWith('i') ? ">" : null; + } +} diff --git a/tests/M0LTE.Tait.Codeplug.Tests/TuiProgressThrottleTests.cs b/tests/M0LTE.Tait.Codeplug.Tests/TuiProgressThrottleTests.cs new file mode 100644 index 0000000..8e0ad28 --- /dev/null +++ b/tests/M0LTE.Tait.Codeplug.Tests/TuiProgressThrottleTests.cs @@ -0,0 +1,76 @@ +using AwesomeAssertions; +using M0LTE.Tait.Codeplug.Cli; +using Xunit; + +namespace M0LTE.Tait.Codeplug.Tests; + +/// +/// A write reports progress once per record, and a full codeplug is over a thousand records. Since +/// Terminal.Gui repaints the whole screen for any change, redrawing on every one of those would push +/// a thousand full repaints down what is often an SSH link. These pin the rules that stop that: only +/// on a change of whole percent, no more often than the interval, and always at the end. +/// +public class TuiProgressThrottleTests +{ + private static readonly DateTime T0 = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void The_first_update_is_drawn() + { + var throttle = new TuiProgressThrottle(TimeSpan.FromMilliseconds(250)); + + throttle.ShouldDraw(0.0, isFinal: false, T0).Should().BeTrue(); + } + + [Fact] + public void Updates_within_the_same_percent_are_skipped() + { + var throttle = new TuiProgressThrottle(TimeSpan.FromMilliseconds(250)); + throttle.ShouldDraw(0.10, isFinal: false, T0).Should().BeTrue(); + + // 1103 records at 10.0x% - the same whole percent, a second later. + throttle.ShouldDraw(0.1004, isFinal: false, T0.AddSeconds(1)).Should().BeFalse(); + throttle.ShouldDraw(0.1009, isFinal: false, T0.AddSeconds(2)).Should().BeFalse(); + } + + [Fact] + public void A_new_percent_too_soon_is_skipped() + { + var throttle = new TuiProgressThrottle(TimeSpan.FromMilliseconds(250)); + throttle.ShouldDraw(0.10, isFinal: false, T0).Should().BeTrue(); + + throttle.ShouldDraw(0.11, isFinal: false, T0.AddMilliseconds(50)).Should().BeFalse(); + throttle.ShouldDraw(0.12, isFinal: false, T0.AddMilliseconds(300)).Should().BeTrue(); + } + + [Fact] + public void The_last_update_is_always_drawn() + { + var throttle = new TuiProgressThrottle(TimeSpan.FromMilliseconds(250)); + throttle.ShouldDraw(0.98, isFinal: false, T0).Should().BeTrue(); + + // Immediately after, and the same percent: still drawn, or the bar stops short of the end. + throttle.ShouldDraw(0.98, isFinal: true, T0.AddMilliseconds(1)).Should().BeTrue(); + } + + [Fact] + public void A_thousand_record_write_costs_a_handful_of_redraws() + { + var throttle = new TuiProgressThrottle(TimeSpan.FromMilliseconds(250)); + const int Records = 1103; + + // A record every 20ms, which is about what the radio manages at 19200 baud. + int drawn = 0; + for (int i = 1; i <= Records; i++) + { + bool last = i == Records; + if (throttle.ShouldDraw((double)i / Records, last, T0.AddMilliseconds(i * 20))) + { + drawn++; + } + } + + drawn.Should().BeLessThan(100, "the point is to redraw a few times a second, not 1103 times"); + drawn.Should().BeGreaterThan(10, "but it still has to look like it is moving"); + } +}