diff --git a/docs/PLAN.md b/docs/PLAN.md
index f726593..c52925a 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -280,8 +280,13 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
`ButtonBinding` ↔ `RioMapEntry.Create` — no hex. The value field is a
context-sensitive picker (keyboard key by name via `KeyCatalog`, joystick Button N,
hat direction, mouse/RIO-command enum); modifiers enable only for keyboard.
- Opened from the tray ("Edit profile…"); Save persists the profile.
- ⏳ Still to refine: live encoder gauges, showing each button's assigned key as a
+ Opened from the tray ("Edit profile…"); Save persists the profile. The encoder
+ gauges are **live**: `RioRuntime.AxesUpdated` streams the calibrated axis values
+ (post invert/deadzone/mix — exactly what the virtual joystick receives) into the
+ strip at the analog poll rate — Z and the L/R pedals fill bottom-up, Rz deflects
+ from its center tick, and the X/Y box tracks the stick as a dot (pure fraction
+ math in `RioJoy.Core.Editing.AxisGauges`, unit-tested).
+ ⏳ Still to refine: showing each button's assigned key as a
caption, grouping a button's two bank addresses, and clone-from-existing.
- The unified profile JSON supersedes both `RIO.ini` and the Google Sheet, with
importers for each.
diff --git a/src/RioJoy.Core/Editing/AxisGauges.cs b/src/RioJoy.Core/Editing/AxisGauges.cs
new file mode 100644
index 0000000..6221cda
--- /dev/null
+++ b/src/RioJoy.Core/Editing/AxisGauges.cs
@@ -0,0 +1,21 @@
+using RioJoy.Core.Calibration;
+
+namespace RioJoy.Core.Editing;
+
+///
+/// Pure math for the editor's live encoder gauges: converts a calibrated axis
+/// value (0..32766, center 16383) into a normalized fill fraction or a
+/// signed center deflection, clamped so out-of-range values can't overdraw a
+/// gauge. UI-free so the conversions are unit-tested; the panel renderer only
+/// scales these to pixels.
+///
+public static class AxisGauges
+{
+ /// Fill fraction 0..1 of an axis value (0 = empty, Max = full).
+ public static double Fraction(int value) =>
+ Math.Min(1.0, Math.Max(0.0, value / (double)AxisOutputs.Max));
+
+ /// Signed deflection -1..+1 from the axis center (0 = centered).
+ public static double Deflection(int value) =>
+ Math.Min(1.0, Math.Max(-1.0, (value - AxisOutputs.Center) / (double)AxisOutputs.Center));
+}
diff --git a/src/RioJoy.Core/RioRuntime.cs b/src/RioJoy.Core/RioRuntime.cs
index fb1d42a..1744b73 100644
--- a/src/RioJoy.Core/RioRuntime.cs
+++ b/src/RioJoy.Core/RioRuntime.cs
@@ -56,6 +56,13 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
///
public event Action? ButtonActivity;
+ ///
+ /// Raised with the six calibrated axis values after every analog reply —
+ /// independent of the joystick sink, so the editor can show live gauges even
+ /// while input routing is suppressed.
+ ///
+ public event Action? AxesUpdated;
+
/// Raised with the firmware version when a arrives.
public event Action? VersionReceived;
@@ -129,6 +136,8 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
{
// Output is best-effort: a faulty joystick sink must not kill the link.
}
+
+ AxesUpdated?.Invoke(axes);
}
private void OnPacket(RioPacket packet)
diff --git a/src/RioJoy.Tray/Editor/PanelCanvas.cs b/src/RioJoy.Tray/Editor/PanelCanvas.cs
index 42e07ba..8a32e20 100644
--- a/src/RioJoy.Tray/Editor/PanelCanvas.cs
+++ b/src/RioJoy.Tray/Editor/PanelCanvas.cs
@@ -1,3 +1,4 @@
+using RioJoy.Core.Calibration;
using RioJoy.Core.Editing;
namespace RioJoy.Tray.Editor;
@@ -31,6 +32,7 @@ internal sealed class PanelCanvas : Control
public Func? CalibrationProvider { get; set; }
private readonly HashSet _livePressed = new();
+ private AxisOutputs? _axes;
public int? SelectedAddress { get; private set; }
@@ -43,6 +45,20 @@ internal sealed class PanelCanvas : Control
Invalidate();
}
+ ///
+ /// Show the latest calibrated axis values on the encoder-gauge strip. Arrives at
+ /// the analog poll rate (~18 Hz), so only the strip is repainted, and only when a
+ /// value actually changed.
+ ///
+ public void SetAxes(AxisOutputs axes)
+ {
+ if (_axes is { } p && p.X == axes.X && p.Y == axes.Y && p.Z == axes.Z &&
+ p.Rx == axes.Rx && p.Ry == axes.Ry && p.Rz == axes.Rz)
+ return;
+ _axes = axes;
+ Invalidate(new Rectangle(0, 0, Width, PanelView.TopStrip));
+ }
+
/// Raised when a [JoyStick] toggle cell is clicked.
public event Action? CalibrationToggled;
@@ -66,6 +82,7 @@ internal sealed class PanelCanvas : Control
a => FunctionProvider?.Invoke(a),
a => LitProvider?.Invoke(a) ?? false,
_livePressed.Contains,
+ _axes,
SelectedAddress);
PanelView.PaintCalibration(e.Graphics, s => CalibrationProvider?.Invoke(s) ?? false);
}
diff --git a/src/RioJoy.Tray/Editor/PanelView.cs b/src/RioJoy.Tray/Editor/PanelView.cs
index edf414a..fc931fc 100644
--- a/src/RioJoy.Tray/Editor/PanelView.cs
+++ b/src/RioJoy.Tray/Editor/PanelView.cs
@@ -1,3 +1,4 @@
+using RioJoy.Core.Calibration;
using RioJoy.Core.Editing;
namespace RioJoy.Tray.Editor;
@@ -74,6 +75,7 @@ public static class PanelView
Func function,
Func lit,
Func live,
+ AxisOutputs? axes,
int? selected)
{
g.Clear(Color.FromArgb(28, 28, 28));
@@ -82,7 +84,7 @@ public static class PanelView
using var subFont = new Font("Segoe UI", 6.75f);
using var groupPen = new Pen(Color.FromArgb(80, 80, 80));
- DrawEncoderStrip(g, titleFont);
+ DrawEncoderStrip(g, titleFont, axes);
// Group frames + titles.
foreach (PanelGroup grp in groups)
@@ -150,10 +152,11 @@ public static class PanelView
}
}
- private static void DrawEncoderStrip(Graphics g, Font font)
+ private static void DrawEncoderStrip(Graphics g, Font font, AxisOutputs? axes)
{
using var pen = new Pen(Color.FromArgb(120, 160, 120));
using var gauge = new SolidBrush(Color.FromArgb(20, 40, 20));
+ using var fill = new SolidBrush(Color.FromArgb(52, 128, 70));
// Centered over the Upper Middle MFD block and arranged to match the physical
// cockpit: Z on the left and the X/Y stick graph on the right (both full
@@ -164,21 +167,61 @@ public static class PanelView
const int bar = 30; // stroke thickness, matching Z's width / Rz bar
int baseX = 6 * CellW - 100; // cluster is 200 wide; 6*CellW centers the MFD
int uX = baseX + 34, uW = 82;
+ var zBox = new Rectangle(baseX, top, bar, fullH);
+ var lBox = new Rectangle(uX, top, bar, fullH); // left arm of the U
+ var rBox = new Rectangle(uX + uW - bar, top, bar, fullH); // right arm of the U
+ var rzBox = new Rectangle(uX, top + fullH, uW, bar); // bottom bar, below L & R (no overlap)
+ var xyBox = new Rectangle(baseX + 120, top, 80, fullH);
(string Label, Rectangle Box)[] boxes =
{
- ("Z", new Rectangle(baseX, top, bar, fullH)),
- ("L", new Rectangle(uX, top, bar, fullH)), // left arm of the U
- ("R", new Rectangle(uX + uW - bar, top, bar, fullH)), // right arm of the U
- ("Rz", new Rectangle(uX, top + fullH, uW, bar)), // bottom bar, below L & R (no overlap)
- ("X / Y", new Rectangle(baseX + 120, top, 80, fullH)),
+ ("Z", zBox), ("L", lBox), ("R", rBox), ("Rz", rzBox), ("X / Y", xyBox),
};
+
+ foreach (var (_, box) in boxes)
+ g.FillRectangle(gauge, box);
+
+ // Live values from the RIO's analog stream (null when the link is down).
+ // The gauges show the calibrated outputs — post invert/deadzone/mix — i.e.
+ // exactly what the virtual joystick receives.
+ if (axes is { } a)
+ {
+ // Z (throttle) and the L/R pedals (Rx/Ry) fill bottom-up.
+ foreach (var (box, value) in new[] { (zBox, a.Z), (lBox, a.Rx), (rBox, a.Ry) })
+ {
+ int h = (int)Math.Round(box.Height * AxisGauges.Fraction(value));
+ if (h > 0)
+ g.FillRectangle(fill, box.X, box.Bottom - h, box.Width, h);
+ }
+
+ // Rz (mixed rudder) deflects left/right from the bar's center.
+ double d = AxisGauges.Deflection(a.Rz);
+ int cx = rzBox.X + rzBox.Width / 2;
+ int w = (int)Math.Round(rzBox.Width / 2.0 * Math.Abs(d));
+ if (w > 0)
+ g.FillRectangle(fill, d < 0 ? cx - w : cx, rzBox.Y, w, rzBox.Height);
+ }
+
+ // Reference marks: Rz center tick + X/Y crosshair (drawn even when idle).
+ g.DrawLine(pen, rzBox.X + rzBox.Width / 2, rzBox.Y, rzBox.X + rzBox.Width / 2, rzBox.Bottom);
+ g.DrawLine(pen, xyBox.X, xyBox.Y + xyBox.Height / 2, xyBox.Right, xyBox.Y + xyBox.Height / 2);
+ g.DrawLine(pen, xyBox.X + xyBox.Width / 2, xyBox.Y, xyBox.X + xyBox.Width / 2, xyBox.Bottom);
+
foreach (var (lbl, box) in boxes)
{
- g.FillRectangle(gauge, box);
g.DrawRectangle(pen, box);
TextRenderer.DrawText(g, lbl, font, box, Color.Gainsboro,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
+
+ // The X/Y stick dot goes on top of the crosshair and label
+ // (0 = left/top, matching the HID axis convention).
+ if (axes is { } live)
+ {
+ int px = xyBox.X + (int)Math.Round(AxisGauges.Fraction(live.X) * (xyBox.Width - 1));
+ int py = xyBox.Y + (int)Math.Round(AxisGauges.Fraction(live.Y) * (xyBox.Height - 1));
+ using var dot = new SolidBrush(Color.FromArgb(150, 230, 150));
+ g.FillEllipse(dot, px - 3, py - 3, 7, 7);
+ }
}
public static PanelButton? HitTest(IReadOnlyList buttons, Point p)
diff --git a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
index d5ab933..b9b0344 100644
--- a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
+++ b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
@@ -1,4 +1,5 @@
using System.Text;
+using RioJoy.Core.Calibration;
using RioJoy.Core.Editing;
using RioJoy.Core.Output;
using RioJoy.Core.Mapping;
@@ -465,6 +466,10 @@ public sealed class ProfileEditorForm : Form
public void ShowLiveActivity(int address, bool pressed) =>
RunOnUi(() => _canvas.SetLivePressed(address, pressed));
+ /// Show the live calibrated axis values on the encoder gauges (serial thread).
+ public void ShowAxes(AxisOutputs axes) =>
+ RunOnUi(() => _canvas.SetAxes(axes));
+
/// Show the firmware version from a RIO version reply (serial thread).
public void ShowVersion(VersionInfo version) =>
RunOnUi(() => { _version = version; RenderStatus(); });
diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs
index c018aad..9eae12f 100644
--- a/src/RioJoy.Tray/TrayApplicationContext.cs
+++ b/src/RioJoy.Tray/TrayApplicationContext.cs
@@ -239,6 +239,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
{
activity = editor.ShowLiveActivity;
runtime.ButtonActivity += activity;
+ runtime.AxesUpdated += editor.ShowAxes;
runtime.VersionReceived += editor.ShowVersion;
runtime.CheckReceived += editor.AddCheckStatus;
}
@@ -248,6 +249,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
if (runtime is not null && activity is not null)
{
runtime.ButtonActivity -= activity;
+ runtime.AxesUpdated -= editor.ShowAxes;
runtime.VersionReceived -= editor.ShowVersion;
runtime.CheckReceived -= editor.AddCheckStatus;
}
diff --git a/tests/RioJoy.Core.Tests/Editing/AxisGaugesTests.cs b/tests/RioJoy.Core.Tests/Editing/AxisGaugesTests.cs
new file mode 100644
index 0000000..4fb8c19
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Editing/AxisGaugesTests.cs
@@ -0,0 +1,32 @@
+using RioJoy.Core.Calibration;
+using RioJoy.Core.Editing;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Editing;
+
+public class AxisGaugesTests
+{
+ [Theory]
+ [InlineData(0, 0.0)]
+ [InlineData(AxisOutputs.Max, 1.0)]
+ [InlineData(-500, 0.0)] // clamps below range
+ [InlineData(AxisOutputs.Max + 500, 1.0)] // clamps above range
+ public void Fraction_MapsAndClamps(int value, double expected) =>
+ Assert.Equal(expected, AxisGauges.Fraction(value), precision: 6);
+
+ [Fact]
+ public void Fraction_Center_IsHalf() =>
+ Assert.Equal(0.5, AxisGauges.Fraction(AxisOutputs.Center), precision: 3);
+
+ [Theory]
+ [InlineData(AxisOutputs.Center, 0.0)]
+ [InlineData(0, -1.0)]
+ [InlineData(-500, -1.0)] // clamps below range
+ [InlineData(AxisOutputs.Max + 500, 1.0)] // clamps above range
+ public void Deflection_MapsAndClamps(int value, double expected) =>
+ Assert.Equal(expected, AxisGauges.Deflection(value), precision: 6);
+
+ [Fact]
+ public void Deflection_Max_IsFullRight() =>
+ Assert.Equal(1.0, AxisGauges.Deflection(AxisOutputs.Max), precision: 3);
+}
diff --git a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs
index 4bdafba..9505f56 100644
--- a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs
+++ b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using AxisOutputs = RioJoy.Core.Calibration.AxisOutputs;
using RioJoy.Core;
using RioJoy.Core.Mapping;
using RioJoy.Core.Protocol;
@@ -72,6 +73,32 @@ public class RioRuntimeTests
await run;
}
+ [Fact]
+ public async Task AnalogReply_RaisesAxesUpdated()
+ {
+ var fake = new FakeTransport();
+ var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
+ var recorder = new RecordingSink();
+
+ using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder);
+ AxisOutputs? seen = null;
+ runtime.AxesUpdated += axes => seen = axes;
+ runtime.Start();
+
+ using var cts = new CancellationTokenSource();
+ Task run = link.RunAsync(cts.Token);
+
+ // All raw axes at zero → joystick X/Y centered, same values the sink receives.
+ fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
+
+ await WaitUntilAsync(() => seen is not null);
+ Assert.Equal(AxisOutputs.Center, seen!.Value.X);
+ Assert.Equal(AxisOutputs.Center, seen.Value.Y);
+
+ cts.Cancel();
+ await run;
+ }
+
[Fact]
public async Task LitLamps_Dimmed_OnFirstBoardReply_NotOnStart()
{