From 6fed4767134c0a7ec38a84e50d875f8e9a1d90af Mon Sep 17 00:00:00 2001 From: Cyd Date: Sun, 28 Jun 2026 21:50:00 -0500 Subject: [PATCH] Editor: live lamp echo, bright-cell feedback, version/status readout While a profile is being edited, light every pressed button on the RIO (RioRuntime.EchoAllLamps, enabled for editor sessions) regardless of mapping, so a physical press always responds. On the panel, a held button now brightens the cell in its own colour (red MFD / blue keypad / yellow board) instead of a cyan outline. Surface the RIO version/check replies (RioRuntime.VersionReceived/ CheckReceived) and show them grouped in a new "RIO reply" box below the command buttons when "Request version & status" is clicked. Drop the two diagnostic readout-toggle buttons (comms bring-up leftovers). Verified against the partial RIO on COM1 (firmware 4.2; MFD + keypad light and report; missing-board status shown). Co-Authored-By: Claude Opus 4.8 --- src/RioJoy.Core/RioRuntime.cs | 31 +++++++- src/RioJoy.Tray/Editor/PanelCanvas.cs | 2 +- src/RioJoy.Tray/Editor/PanelView.cs | 32 +++----- src/RioJoy.Tray/Editor/ProfileEditorForm.cs | 86 +++++++++++++++++++-- src/RioJoy.Tray/RioCoordinator.cs | 2 + src/RioJoy.Tray/TrayApplicationContext.cs | 6 ++ 6 files changed, 127 insertions(+), 32 deletions(-) diff --git a/src/RioJoy.Core/RioRuntime.cs b/src/RioJoy.Core/RioRuntime.cs index ae8e388..1759a8f 100644 --- a/src/RioJoy.Core/RioRuntime.cs +++ b/src/RioJoy.Core/RioRuntime.cs @@ -18,6 +18,7 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable private readonly IJoystickSink _joystick; private readonly AxisCalibrator _calibrator; private readonly InputRouter _router; + private readonly ILampSink _lamp; private bool _started; public RioRuntime( @@ -33,10 +34,17 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable _joystick = joystick ?? throw new ArgumentNullException(nameof(joystick)); _calibrator = calibrator ?? new AxisCalibrator(); - var lamp = new SerialLampSink(link); - _router = new InputRouter(map, input, joystick, lamp, this); + _lamp = new SerialLampSink(link); + _router = new InputRouter(map, input, joystick, _lamp, this); } + /// + /// When set, light every pressed button on the RIO (bright on press, dim on + /// release) regardless of how — or whether — it is mapped. Used by the editor so + /// a physical press lights the button even with an empty/partial profile. + /// + public bool EchoAllLamps { get; set; } + /// Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate). public event Action? DiagnosticToggle; @@ -47,6 +55,12 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable /// public event Action? ButtonActivity; + /// Raised with the firmware version when a arrives. + public event Action? VersionReceived; + + /// Raised for each board/lamp status item in a . + public event Action? CheckReceived; + /// Subscribe to the link and set all lamps to their idle state. public void Start() { @@ -56,6 +70,8 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable _link.PacketReceived += OnPacket; _link.AnalogReceived += OnAnalog; + _link.VersionReceived += OnVersion; + _link.CheckReceived += OnCheck; _router.InitializeLamps(); } @@ -68,8 +84,14 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable _link.PacketReceived -= OnPacket; _link.AnalogReceived -= OnAnalog; + _link.VersionReceived -= OnVersion; + _link.CheckReceived -= OnCheck; } + private void OnVersion(VersionInfo version) => VersionReceived?.Invoke(version); + + private void OnCheck(CheckStatus status) => CheckReceived?.Invoke(status); + public void Dispose() => Stop(); /// Invoke a RIO command directly (e.g. from the tray menu). @@ -114,6 +136,11 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable else _router.Release(address); + // Light the button on the RIO even when it is unmapped (the router only lamps + // mapped lamp-buttons), so the editor's check-function workflow always responds. + if (EchoAllLamps) + _lamp.SetLamp(address, pressed ? RioLampState.SolidBright : RioLampState.SolidDim); + ButtonActivity?.Invoke(address, pressed); } diff --git a/src/RioJoy.Tray/Editor/PanelCanvas.cs b/src/RioJoy.Tray/Editor/PanelCanvas.cs index 4ea6c45..65021b5 100644 --- a/src/RioJoy.Tray/Editor/PanelCanvas.cs +++ b/src/RioJoy.Tray/Editor/PanelCanvas.cs @@ -67,9 +67,9 @@ internal sealed class PanelCanvas : Control a => LabelProvider?.Invoke(a), a => FunctionProvider?.Invoke(a), a => LitProvider?.Invoke(a) ?? false, + _livePressed.Contains, SelectedAddress); PanelView.PaintCalibration(e.Graphics, s => CalibrationProvider?.Invoke(s) ?? false); - PanelView.PaintLivePressed(e.Graphics, AllButtons, _livePressed); } protected override void OnMouseDown(MouseEventArgs e) diff --git a/src/RioJoy.Tray/Editor/PanelView.cs b/src/RioJoy.Tray/Editor/PanelView.cs index 9bd6ff8..7f8df8a 100644 --- a/src/RioJoy.Tray/Editor/PanelView.cs +++ b/src/RioJoy.Tray/Editor/PanelView.cs @@ -75,6 +75,7 @@ public static class PanelView Func label, Func function, Func lit, + Func live, int? selected) { g.Clear(Color.FromArgb(28, 28, 28)); @@ -109,14 +110,18 @@ public static class PanelView string? fn = function(b.Address); bool assigned = !string.IsNullOrEmpty(fn); + // A live (physically held) button lights bright in its own colour, as does + // a lamp configured ON; otherwise the cell shows its idle/dim shade. bool yellow = b.Group.Title is "Secondary" or "Screen"; + bool bright = lit(b.Address) || live(b.Address); Color fill = !b.LampCapable - ? Color.FromArgb(150, 182, 226) // keypad — neutral blue + ? (live(b.Address) ? Color.FromArgb(205, 228, 255) // keypad — bright blue when pressed + : Color.FromArgb(150, 182, 226)) // keypad — neutral blue : yellow - ? (lit(b.Address) ? Color.FromArgb(240, 205, 50) // Secondary/Screen — yellow lamp - : Color.FromArgb(120, 102, 30)) // Secondary/Screen — dim yellow - : lit(b.Address) - ? Color.FromArgb(215, 60, 60) // lamp ON (IsLit checked) + ? (bright ? Color.FromArgb(240, 205, 50) // Secondary/Screen — yellow lamp + : Color.FromArgb(120, 102, 30)) // Secondary/Screen — dim yellow + : bright + ? Color.FromArgb(215, 60, 60) // lamp ON (lit or held) : Color.FromArgb(86, 48, 48); // lamp OFF using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r); @@ -215,23 +220,6 @@ public static class PanelView } } - /// - /// Outline the buttons currently held down on the live RIO (the editor's - /// check-button-function feedback), drawn over the normal cells. - /// - public static void PaintLivePressed(Graphics g, IReadOnlyList buttons, ISet pressed) - { - if (pressed.Count == 0) - return; - using var pen = new Pen(Color.FromArgb(90, 220, 255), 3f); // cyan = live press - foreach (PanelButton b in buttons) - if (pressed.Contains(b.Address)) - { - Rectangle r = Cell(b.Col, b.Row); - g.DrawRectangle(pen, r.X, r.Y, r.Width - 1, r.Height - 1); - } - } - /// Hit-test the [JoyStick] toggle cells; null if the point misses. public static CalibrationCell? HitTestCalibration(Point p) { diff --git a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs index 0e78907..c716d51 100644 --- a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs +++ b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs @@ -1,8 +1,10 @@ using System.Runtime.Versioning; +using System.Text; using RioJoy.Core.Editing; using RioJoy.Core.Hid; using RioJoy.Core.Mapping; using RioJoy.Core.Profiles; +using RioJoy.Core.Protocol; namespace RioJoy.Tray.Editor; @@ -42,9 +44,26 @@ public sealed class ProfileEditorForm : Form private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 240), Width = 110 }; private readonly Button _close = new() { Text = "Close", Location = new Point(190, 240), Width = 80 }; + private readonly TextBox _statusBox = new() + { + Location = new Point(12, 566), + Size = new Size(306, 190), + Multiline = true, + ReadOnly = true, + ScrollBars = ScrollBars.Vertical, + BackColor = Color.FromArgb(24, 24, 24), + ForeColor = Color.Gainsboro, + Font = new Font("Consolas", 8f), + Text = "Press \"Request version & status\" to query the RIO.", + }; + private int? _address; private bool _loading; + // The most recent version/status reply, accumulated as CheckReply items stream in. + private VersionInfo? _version; + private readonly List _checks = new(); + // The RIO device commands, mirroring the tray menu, surfaced as live buttons so // they can be fired against the RIO while editing (the editor holds the port). private static readonly (string Label, RioCommandCode Code)[] RioCommands = @@ -57,8 +76,6 @@ public sealed class ProfileEditorForm : Form ("Reset horizontal joystick", RioCommandCode.ResetHorizontalJoystick), ("Reset digital (general)", RioCommandCode.GeneralReset), ("Request version & status", RioCommandCode.RequestVersionAndCheck), - ("Toggle raw-axes readout", RioCommandCode.ToggleRawAxesReadout), - ("Toggle poll-rate readout", RioCommandCode.TogglePollRateReadout), }; /// A pick-list entry: what the user sees and the byte it maps to. @@ -125,6 +142,8 @@ public sealed class ProfileEditorForm : Form panel.Controls.Add(_valueCombo); panel.Controls.AddRange(new Control[] { _shift, _ctrl, _alt, _ext, _lit, _apply, _unassign, _save, _close }); panel.Controls.Add(BuildCommandGroup()); + panel.Controls.Add(new Label { Text = "RIO reply:", Location = new Point(12, 548), AutoSize = true }); + panel.Controls.Add(_statusBox); return panel; } @@ -132,14 +151,24 @@ public sealed class ProfileEditorForm : Form // A button per RIO device command, fired against the live RIO via CommandRequested. private GroupBox BuildCommandGroup() { - var group = new GroupBox { Text = "RIO commands (live)", Location = new Point(12, 278), Size = new Size(306, 330) }; + var group = new GroupBox { Text = "RIO commands (live)", Location = new Point(12, 278), Size = new Size(306, 262) }; int y = 24; foreach ((string label, RioCommandCode code) in RioCommands) { RioCommandCode captured = code; var button = new Button { Text = label, Location = new Point(10, y), Width = 286, Height = 26, UseMnemonic = false }; - button.Click += (_, _) => CommandRequested?.Invoke(captured); + button.Click += (_, _) => + { + // The version/status reply streams back into the box below; clear it first. + if (captured == RioCommandCode.RequestVersionAndCheck) + { + _version = null; + _checks.Clear(); + _statusBox.Text = "Requesting version & status…"; + } + CommandRequested?.Invoke(captured); + }; group.Controls.Add(button); y += 29; } @@ -347,17 +376,60 @@ public sealed class ProfileEditorForm : Form /// Lets the user press a physical button and see which cell it is without any /// keystrokes being injected. /// - public void ShowLiveActivity(int address, bool pressed) + public void ShowLiveActivity(int address, bool pressed) => + RunOnUi(() => _canvas.SetLivePressed(address, pressed)); + + /// Show the firmware version from a RIO version reply (serial thread). + public void ShowVersion(VersionInfo version) => + RunOnUi(() => { _version = version; RenderStatus(); }); + + /// Append a board/lamp status item from a RIO check reply (serial thread). + public void AddCheckStatus(CheckStatus status) => + RunOnUi(() => { _checks.Add(status); RenderStatus(); }); + + // Marshal an action onto the UI thread, tolerating a closing/disposed form. + private void RunOnUi(Action action) { if (IsDisposed) return; if (InvokeRequired) { - try { BeginInvoke(() => ShowLiveActivity(address, pressed)); } + try { BeginInvoke(action); } catch (ObjectDisposedException) { } catch (InvalidOperationException) { } // handle not created / form closing return; } - _canvas.SetLivePressed(address, pressed); + action(); } + + // Render the accumulated version + check reply into the status box. + private void RenderStatus() + { + var sb = new StringBuilder(); + sb.AppendLine(_version is { } v ? $"Firmware: {v}" : "Firmware: (awaiting reply…)"); + + if (_checks.Count == 0) + { + sb.Append("Status: (awaiting reply…)"); + } + else + { + foreach (IGrouping group in _checks.GroupBy(c => c.Type)) + sb.AppendLine($"{Friendly(group.Key)}: {string.Join(", ", group.Select(c => c.Number))}"); + } + + _statusBox.Text = sb.ToString().TrimEnd(); + } + + private static string Friendly(RioStatusType type) => type switch + { + RioStatusType.BoardOk => "Boards OK", + RioStatusType.BoardMissing => "Boards missing", + RioStatusType.BoardBad => "Boards bad", + RioStatusType.LampBad => "Lamps bad", + RioStatusType.RestartCount => "Restart count", + RioStatusType.AbandonCount => "Abandon count", + RioStatusType.FullBufferCount => "Full-buffer count", + _ => type.ToString(), + }; } diff --git a/src/RioJoy.Tray/RioCoordinator.cs b/src/RioJoy.Tray/RioCoordinator.cs index 3420040..bf55eff 100644 --- a/src/RioJoy.Tray/RioCoordinator.cs +++ b/src/RioJoy.Tray/RioCoordinator.cs @@ -176,6 +176,8 @@ public sealed class RioCoordinator : IDisposable joystick, new AxisCalibrator(profile.Calibration)); + _runtime.EchoAllLamps = !routeInput; // editor: light any pressed button on the RIO + _cts = new CancellationTokenSource(); _ = _link.RunAsync(_cts.Token); _runtime.Start(); diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs index 204feb7..6e2e93c 100644 --- a/src/RioJoy.Tray/TrayApplicationContext.cs +++ b/src/RioJoy.Tray/TrayApplicationContext.cs @@ -155,12 +155,18 @@ internal sealed class TrayApplicationContext : ApplicationContext { activity = editor.ShowLiveActivity; runtime.ButtonActivity += activity; + runtime.VersionReceived += editor.ShowVersion; + runtime.CheckReceived += editor.AddCheckStatus; } editor.FormClosed += (_, _) => { if (runtime is not null && activity is not null) + { runtime.ButtonActivity -= activity; + runtime.VersionReceived -= editor.ShowVersion; + runtime.CheckReceived -= editor.AddCheckStatus; + } _coordinator.EndEditorSession(); _watcher.Reset(); // re-sync with the foreground app (may re-activate a game) _watcher.Poll();