Files
riojoy/src/RioJoy.Core/RioRuntime.cs
T
CydandClaude Fable 5 e60d551f39 Phase 7: live encoder gauges in the profile editor
RioRuntime raises AxesUpdated with the calibrated AxisOutputs after every
analog reply (independent of the joystick sink, so the gauges work while
editor input routing is suppressed). The editor strip now renders the
values live: Z and the L/R pedals (Rx/Ry) fill bottom-up, Rz deflects
from a center tick, and the X/Y box tracks the stick as a dot over a
crosshair. Fraction/deflection math is pure in Core.Editing.AxisGauges
(clamped, unit-tested); the canvas repaints only the strip and only on
change. Verified by offline form screenshots against injected values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:37:29 -05:00

224 lines
8.2 KiB
C#

using RioJoy.Core.Calibration;
using RioJoy.Core.Mapping;
using RioJoy.Core.Protocol;
using RioJoy.Core.Serial;
namespace RioJoy.Core;
/// <summary>
/// Ties the live RIO link to the input/output pipeline for one active profile:
/// serial button/keypad packets drive the <see cref="InputRouter"/>; analog
/// replies drive the <see cref="AxisCalibrator"/> and the six joystick axes; RIO
/// commands trigger calibration resets and serial requests. This is the runtime
/// assembled per-profile by the tray app (Phase 5).
/// </summary>
public sealed class RioRuntime : IRioCommandSink, IDisposable
{
private readonly RioSerialLink _link;
private readonly IJoystickSink _joystick;
private readonly AxisCalibrator _calibrator;
private readonly InputRouter _router;
private readonly ILampSink _lamp;
private bool _started;
private bool _lampsInitialized;
public RioRuntime(
RioSerialLink link,
RioInputMap map,
IInputSink input,
IJoystickSink joystick,
AxisCalibrator? calibrator = null)
{
_link = link ?? throw new ArgumentNullException(nameof(link));
if (map is null) throw new ArgumentNullException(nameof(map));
if (input is null) throw new ArgumentNullException(nameof(input));
_joystick = joystick ?? throw new ArgumentNullException(nameof(joystick));
_calibrator = calibrator ?? new AxisCalibrator();
_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;
/// <summary>
/// Raised for every decoded button/keypad event (RIO address, <c>true</c> on press
/// and <c>false</c> on release) — independent of how the entry is routed, so the
/// editor can show live button activity even with no-op output sinks.
/// </summary>
public event Action<int, bool>? ButtonActivity;
/// <summary>
/// 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.
/// </summary>
public event Action<AxisOutputs>? AxesUpdated;
/// <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. Lit lamps are dimmed on the first reply from the board
/// (<see cref="EnsureLampsInitialized"/>) rather than here: lamp commands sent in
/// the first milliseconds after the DTR reset (on port open) are dropped before
/// the board finishes booting, so the idle-dim state never takes.
/// </summary>
public void Start()
{
if (_started)
return;
_started = true;
_lampsInitialized = false;
_link.PacketReceived += OnPacket;
_link.AnalogReceived += OnAnalog;
_link.VersionReceived += OnVersion;
_link.CheckReceived += OnCheck;
}
/// <summary>Unsubscribe from the link.</summary>
public void Stop()
{
if (!_started)
return;
_started = false;
_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>
public void Trigger(RioCommandCode command) => ((IRioCommandSink)this).Execute(command);
// Once the board replies (alive after the reset), set every lit button to dim.
private void EnsureLampsInitialized()
{
if (_lampsInitialized)
return;
_lampsInitialized = true;
_router.InitializeLamps();
}
private void OnAnalog(AnalogReport report)
{
EnsureLampsInitialized();
AxisOutputs axes = _calibrator.Update(report);
try
{
_joystick.SetAxis(JoyAxis.X, axes.X);
_joystick.SetAxis(JoyAxis.Y, axes.Y);
_joystick.SetAxis(JoyAxis.Z, axes.Z);
_joystick.SetAxis(JoyAxis.Rx, axes.Rx);
_joystick.SetAxis(JoyAxis.Ry, axes.Ry);
_joystick.SetAxis(JoyAxis.Rz, axes.Rz);
}
catch
{
// Output is best-effort: a faulty joystick sink must not kill the link.
}
AxesUpdated?.Invoke(axes);
}
private void OnPacket(RioPacket packet)
{
EnsureLampsInitialized();
ReadOnlySpan<byte> p = packet.Payload.Span;
switch (packet.Command)
{
case RioCommand.ButtonPressed when p[0] < RioAddress.ButtonCount:
Activity(RioAddress.FromButton(p[0]), pressed: true);
break;
case RioCommand.ButtonReleased when p[0] < RioAddress.ButtonCount:
Activity(RioAddress.FromButton(p[0]), pressed: false);
break;
case RioCommand.KeyPressed when IsKeypad(p):
Activity(RioAddress.FromKeypad(p[0], p[1]), pressed: true);
break;
case RioCommand.KeyReleased when IsKeypad(p):
Activity(RioAddress.FromKeypad(p[0], p[1]), pressed: false);
break;
}
}
// Route the event through the input pipeline and notify any live-activity listener.
private void Activity(int address, bool pressed)
{
try
{
if (pressed)
_router.Press(address);
else
_router.Release(address);
}
catch
{
// Output is best-effort: a faulty sink must never kill the serial link.
}
// 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);
}
private static bool IsKeypad(ReadOnlySpan<byte> p) => p[0] is 0 or 1 && p[1] <= 0x0F;
// IRioCommandSink: RIO commands routed from a button (RIOcmd port).
void IRioCommandSink.Execute(RioCommandCode command)
{
switch (command)
{
case RioCommandCode.ResetAllAxes:
case RioCommandCode.ResetThrottle:
case RioCommandCode.ResetLeftPedal:
case RioCommandCode.ResetRightPedal:
case RioCommandCode.ResetVerticalJoystick:
case RioCommandCode.ResetHorizontalJoystick:
_calibrator.Reset(command);
FireAndForget(_link.ResetAsync((RioResetTarget)(byte)command));
break;
case RioCommandCode.GeneralReset:
FireAndForget(_link.ResetAsync(RioResetTarget.All));
_router.InitializeLamps();
break;
case RioCommandCode.RequestVersionAndCheck:
FireAndForget(_link.RequestVersionAsync());
FireAndForget(_link.RequestCheckAsync());
break;
case RioCommandCode.ToggleRawAxesReadout:
case RioCommandCode.TogglePollRateReadout:
DiagnosticToggle?.Invoke(command);
break;
}
}
private static void FireAndForget(Task task) =>
task.ContinueWith(static t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
}