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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-28 21:50:00 -05:00
co-authored by Claude Opus 4.8
parent 36bf1c0190
commit 6fed476713
6 changed files with 127 additions and 32 deletions
+29 -2
View File
@@ -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);
}
/// <summary>
/// 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.
/// </summary>
public bool EchoAllLamps { get; set; }
/// <summary>Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate).</summary>
public event Action<RioCommandCode>? DiagnosticToggle;
@@ -47,6 +55,12 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
/// </summary>
public event Action<int, bool>? ButtonActivity;
/// <summary>Raised with the firmware version when a <see cref="RioCommand.VersionReply"/> arrives.</summary>
public event Action<VersionInfo>? VersionReceived;
/// <summary>Raised for each board/lamp status item in a <see cref="RioCommand.CheckReply"/>.</summary>
public event Action<CheckStatus>? CheckReceived;
/// <summary>Subscribe to the link and set all lamps to their idle state.</summary>
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();
/// <summary>Invoke a RIO command directly (e.g. from the tray menu).</summary>
@@ -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);
}
+1 -1
View File
@@ -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)
+10 -22
View File
@@ -75,6 +75,7 @@ public static class PanelView
Func<int, string?> label,
Func<int, string?> function,
Func<int, bool> lit,
Func<int, bool> 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
}
}
/// <summary>
/// Outline the buttons currently held down on the live RIO (the editor's
/// check-button-function feedback), drawn over the normal cells.
/// </summary>
public static void PaintLivePressed(Graphics g, IReadOnlyList<PanelButton> buttons, ISet<int> 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);
}
}
/// <summary>Hit-test the <c>[JoyStick]</c> toggle cells; null if the point misses.</summary>
public static CalibrationCell? HitTestCalibration(Point p)
{
+79 -7
View File
@@ -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<CheckStatus> _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),
};
/// <summary>A pick-list entry: what the user sees and the byte it maps to.</summary>
@@ -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.
/// </summary>
public void ShowLiveActivity(int address, bool pressed)
public void ShowLiveActivity(int address, bool pressed) =>
RunOnUi(() => _canvas.SetLivePressed(address, pressed));
/// <summary>Show the firmware version from a RIO version reply (serial thread).</summary>
public void ShowVersion(VersionInfo version) =>
RunOnUi(() => { _version = version; RenderStatus(); });
/// <summary>Append a board/lamp status item from a RIO check reply (serial thread).</summary>
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<RioStatusType, CheckStatus> 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(),
};
}
+2
View File
@@ -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();
@@ -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();