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>
233 lines
8.6 KiB
C#
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);
|
|
}
|