using RioJoy.Core.Calibration; using RioJoy.Core.Mapping; using RioJoy.Core.Protocol; using RioJoy.Core.Serial; namespace RioJoy.Core; /// /// Ties the live RIO link to the input/output pipeline for one active profile: /// serial button/keypad packets drive the ; analog /// replies drive the 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). /// 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); } /// /// 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. /// public bool EchoAllLamps { get; set; } /// Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate). public event Action? DiagnosticToggle; /// /// Raised for every decoded button/keypad event (RIO address, true on press /// and false on release) — independent of how the entry is routed, so the /// editor can show live button activity even with no-op output sinks. /// 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; /// Raised for each board/lamp status item in a . public event Action? CheckReceived; /// /// Subscribe to the link. Lit lamps are dimmed on the first reply from the board /// () 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. /// public void Start() { if (_started) return; _started = true; _lampsInitialized = false; _link.PacketReceived += OnPacket; _link.AnalogReceived += OnAnalog; _link.VersionReceived += OnVersion; _link.CheckReceived += OnCheck; } /// Unsubscribe from the link. 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(); /// Invoke a RIO command directly (e.g. from the tray menu). 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 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 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); }