Files
riojoy/src/RioJoy.Core/RioRuntime.cs
T
CydandClaude Fable 5 ad7ac19ab2 feedback: game-to-cockpit endpoint (pipe/UDP lamps+plasma), rumble lamp flash
Phase 9: FeedbackPipeServer (\\.\pipe\riojoy-feedback) + loopback UDP share a
forgiving text line protocol into FeedbackRouter; CoalescingLampScheduler rate-
governs the 9600-baud link; plasma finally wired into activation (greeting,
teardown blank, PlasmaDisplay write lock); ViGEm FeedbackReceived drives
RumbleLampAdapter. Per-profile Feedback config, docs/FEEDBACK.md, 425 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 19:31:03 -05:00

233 lines
8.6 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>
/// This runtime's serial lamp sink — the target the inbound feedback
/// endpoint's scheduler drives (Phase 9). Feedback paths must rate-limit
/// through a <see cref="Feedback.CoalescingLampScheduler"/>, never call
/// <see cref="ILampSink.SetLamp"/> directly (see its remarks).
/// </summary>
public ILampSink Lamps => _lamp;
/// <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 calibrated readout (virtual axes + pre-mix pedal positions)
/// 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<AxisReadout>? 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(new AxisReadout(
axes, _calibrator.LeftPedalOutput, _calibrator.RightPedalOutput));
}
private void OnPacket(RioPacket packet)
{
EnsureLampsInitialized();
byte[] p = packet.Payload;
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(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);
}