Phase 5: tray app + profiles + runtime wiring
Wire the Core pieces into a runnable tray app with per-game profiles and the three-state serial-yield auto-switch: - Profiles/: RioProfile + AppConfig model; ConfigStore (System.Text.Json, round-tripped); RioIniImporter ports the legacy RIO.ini (button table, invert flags, plasma greeting); AutoSwitchResolver + AutoSwitchWatcher resolve the foreground executable into Yield (native game) / Activate (profile) / Idle, with native always winning and change-only notifications. IForegroundProcessProvider abstracts the OS. - RioRuntime assembles a profile's live pipeline: serial ButtonPressed/Released + KeyPressed/Released → InputRouter (via RioAddress); AnalogReply → AxisCalibrator → the six joystick axes; RIO commands → calibration resets + version/check requests + lamp re-init. SerialLampSink sends lamp feedback over the link; NullJoystickSink is a placeholder until the Phase 1 HID feeder exists. - RioJoy.Tray: NotifyIcon menu mirroring the legacy console menu (axis resets, version/status, raw-axes & poll-rate toggles, quit) + profile selection (auto vs. manual) + "start with Windows"; RioCoordinator owns the serial acquire/release tied to the watcher (native-game COM-port yield). OS adapters: ForegroundProcessProvider (Win32 foreground PID→exe) and AutoStartManager (HKCU Run key). - tests: 18 new xUnit tests (123 total) for config round-trip, ini import, the three-state resolver + watcher, and RioRuntime end-to-end over the fake transport (button→joystick, keypad-offset→keyboard, analog→six axes). The joystick output stays a no-op until the Phase 1 driver; on-cabinet verification of the acquire/release lifecycle remains. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
using RioJoy.Core.Calibration;
|
||||
|
||||
namespace RioJoy.Core.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// A no-op <see cref="IJoystickSink"/> placeholder used until the real HID feeder
|
||||
/// (→ RioGamepad driver via DeviceIoControl, Phase 1) exists. Lets the runtime be
|
||||
/// assembled and exercised end-to-end without a virtual joystick present.
|
||||
/// </summary>
|
||||
public sealed class NullJoystickSink : IJoystickSink
|
||||
{
|
||||
public void SetButton(int button, bool pressed) { }
|
||||
|
||||
public void SetHat(RioHat position) { }
|
||||
|
||||
public void SetAxis(JoyAxis axis, int value) { }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace RioJoy.Core.Profiles;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level RIOJoy configuration: the profile library plus the global settings
|
||||
/// that drive the three-state auto-switch (see docs/PLAN.md §Profiles). Persisted
|
||||
/// as JSON by <see cref="ConfigStore"/>.
|
||||
/// </summary>
|
||||
public sealed class AppConfig
|
||||
{
|
||||
/// <summary>Default RIO COM port when a profile doesn't specify one.</summary>
|
||||
public string DefaultRioComPort { get; set; } = "COM1";
|
||||
|
||||
/// <summary>Default plasma COM port when a profile doesn't specify one.</summary>
|
||||
public string? DefaultPlasmaComPort { get; set; } = "COM2";
|
||||
|
||||
/// <summary>
|
||||
/// Executable names of the native games to yield to. When one of these is the
|
||||
/// foreground app, RIOJoy releases the COM port and goes dormant. Matched
|
||||
/// case-insensitively, with or without ".exe".
|
||||
/// </summary>
|
||||
public List<string> NativeGameExecutables { get; set; } = new();
|
||||
|
||||
/// <summary>The per-game profile library.</summary>
|
||||
public List<RioProfile> Profiles { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional profile to activate when nothing else matches (desktop/idle). Null
|
||||
/// means stay idle / release the port.
|
||||
/// </summary>
|
||||
public string? NeutralProfileName { get; set; }
|
||||
|
||||
/// <summary>Start RIOJoy with Windows.</summary>
|
||||
public bool AutoStart { get; set; }
|
||||
|
||||
/// <summary>Find a profile by name (case-insensitive), or null.</summary>
|
||||
public RioProfile? FindProfile(string? name) =>
|
||||
name is null
|
||||
? null
|
||||
: Profiles.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
namespace RioJoy.Core.Profiles;
|
||||
|
||||
/// <summary>The three states of the serial-port yield / profile auto-switch.</summary>
|
||||
public enum SwitchMode
|
||||
{
|
||||
/// <summary>A native game is foreground — release the COM port and go dormant.</summary>
|
||||
Yield,
|
||||
|
||||
/// <summary>A supported non-native game is foreground — run its profile.</summary>
|
||||
Activate,
|
||||
|
||||
/// <summary>Nothing matched — idle (port released or a neutral default).</summary>
|
||||
Idle,
|
||||
}
|
||||
|
||||
/// <summary>The decision produced by <see cref="AutoSwitchResolver"/>.</summary>
|
||||
public readonly struct SwitchDecision : IEquatable<SwitchDecision>
|
||||
{
|
||||
public SwitchMode Mode { get; }
|
||||
|
||||
/// <summary>The profile to run (set when <see cref="Mode"/> is <see cref="SwitchMode.Activate"/>).</summary>
|
||||
public RioProfile? Profile { get; }
|
||||
|
||||
private SwitchDecision(SwitchMode mode, RioProfile? profile)
|
||||
{
|
||||
Mode = mode;
|
||||
Profile = profile;
|
||||
}
|
||||
|
||||
public static SwitchDecision Yield() => new(SwitchMode.Yield, null);
|
||||
public static SwitchDecision Idle() => new(SwitchMode.Idle, null);
|
||||
public static SwitchDecision Activate(RioProfile profile) => new(SwitchMode.Activate, profile);
|
||||
|
||||
public bool Equals(SwitchDecision other) => Mode == other.Mode && ReferenceEquals(Profile, other.Profile);
|
||||
public override bool Equals(object? obj) => obj is SwitchDecision d && Equals(d);
|
||||
public override int GetHashCode() => HashCode.Combine(Mode, Profile);
|
||||
public override string ToString() => Mode == SwitchMode.Activate ? $"Activate({Profile?.Name})" : Mode.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the foreground executable into a <see cref="SwitchDecision"/>, the
|
||||
/// pure core of the auto-switch state machine (see docs/PLAN.md §Profiles).
|
||||
/// Precedence: a native game always wins (yield); otherwise the first profile that
|
||||
/// lists the executable activates; otherwise the neutral profile (if any) or idle.
|
||||
/// </summary>
|
||||
public static class AutoSwitchResolver
|
||||
{
|
||||
/// <summary>Normalize an executable name for matching: basename, no ".exe", lower-case.</summary>
|
||||
public static string Normalize(string executable)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(executable);
|
||||
string name = executable.Trim();
|
||||
// Strip any path (handle both separators regardless of OS).
|
||||
int slash = name.LastIndexOfAny(new[] { '/', '\\' });
|
||||
if (slash >= 0)
|
||||
name = name[(slash + 1)..];
|
||||
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
name = name[..^4];
|
||||
return name.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool Matches(IEnumerable<string> patterns, string normalizedExe) =>
|
||||
patterns.Any(p => Normalize(p) == normalizedExe);
|
||||
|
||||
/// <summary>
|
||||
/// Decide what RIOJoy should do given the current foreground executable
|
||||
/// (<paramref name="foregroundExecutable"/> may be null when unknown/desktop).
|
||||
/// </summary>
|
||||
public static SwitchDecision Resolve(AppConfig config, string? foregroundExecutable)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(foregroundExecutable))
|
||||
{
|
||||
string exe = Normalize(foregroundExecutable);
|
||||
|
||||
if (Matches(config.NativeGameExecutables, exe))
|
||||
return SwitchDecision.Yield();
|
||||
|
||||
foreach (RioProfile profile in config.Profiles)
|
||||
{
|
||||
if (Matches(profile.MatchExecutables, exe))
|
||||
return SwitchDecision.Activate(profile);
|
||||
}
|
||||
}
|
||||
|
||||
RioProfile? neutral = config.FindProfile(config.NeutralProfileName);
|
||||
return neutral is not null ? SwitchDecision.Activate(neutral) : SwitchDecision.Idle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace RioJoy.Core.Profiles;
|
||||
|
||||
/// <summary>Supplies the current foreground application's executable name.</summary>
|
||||
public interface IForegroundProcessProvider
|
||||
{
|
||||
/// <summary>The foreground executable name (e.g. "game.exe"), or null if unknown.</summary>
|
||||
string? GetForegroundExecutable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls the foreground process and resolves the three-state auto-switch decision,
|
||||
/// raising <see cref="DecisionChanged"/> only when the decision actually changes.
|
||||
/// The polling cadence is driven by the host; <see cref="Poll"/> performs one
|
||||
/// resolution so the logic is deterministic and unit-testable.
|
||||
/// </summary>
|
||||
public sealed class AutoSwitchWatcher
|
||||
{
|
||||
private readonly IForegroundProcessProvider _provider;
|
||||
private readonly Func<AppConfig> _configAccessor;
|
||||
private SwitchDecision? _last;
|
||||
|
||||
public AutoSwitchWatcher(IForegroundProcessProvider provider, Func<AppConfig> configAccessor)
|
||||
{
|
||||
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
|
||||
_configAccessor = configAccessor ?? throw new ArgumentNullException(nameof(configAccessor));
|
||||
}
|
||||
|
||||
/// <summary>Raised when the resolved decision differs from the previous one.</summary>
|
||||
public event Action<SwitchDecision>? DecisionChanged;
|
||||
|
||||
/// <summary>The most recently resolved decision (null until the first <see cref="Poll"/>).</summary>
|
||||
public SwitchDecision? Current => _last;
|
||||
|
||||
/// <summary>Resolve once; raise <see cref="DecisionChanged"/> if it changed. Returns the decision.</summary>
|
||||
public SwitchDecision Poll()
|
||||
{
|
||||
string? exe = _provider.GetForegroundExecutable();
|
||||
SwitchDecision decision = AutoSwitchResolver.Resolve(_configAccessor(), exe);
|
||||
|
||||
if (_last is null || !_last.Value.Equals(decision))
|
||||
{
|
||||
_last = decision;
|
||||
DecisionChanged?.Invoke(decision);
|
||||
}
|
||||
|
||||
return decision;
|
||||
}
|
||||
|
||||
/// <summary>Poll on <paramref name="interval"/> until cancelled.</summary>
|
||||
public async Task RunAsync(TimeSpan interval, CancellationToken cancellationToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(interval);
|
||||
Poll();
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
|
||||
Poll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RioJoy.Core.Profiles;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and saves <see cref="AppConfig"/> as JSON. Replaces the legacy
|
||||
/// SimpleIni single-file config with a profile library (see docs/PLAN.md).
|
||||
/// </summary>
|
||||
public static class ConfigStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public static string Serialize(AppConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
return JsonSerializer.Serialize(config, Options);
|
||||
}
|
||||
|
||||
public static AppConfig Deserialize(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
||||
return JsonSerializer.Deserialize<AppConfig>(json, Options)
|
||||
?? throw new JsonException("Config JSON deserialized to null.");
|
||||
}
|
||||
|
||||
/// <summary>Save the config to <paramref name="path"/> (creating directories).</summary>
|
||||
public static void Save(AppConfig config, string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
string? dir = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(path, Serialize(config));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load the config from <paramref name="path"/>, or return a fresh default
|
||||
/// <see cref="AppConfig"/> if the file does not exist.
|
||||
/// </summary>
|
||||
public static AppConfig Load(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
return File.Exists(path) ? Deserialize(File.ReadAllText(path)) : new AppConfig();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace RioJoy.Core.Profiles;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal INI reader (sections, <c>key = value</c>, <c>;</c>/<c>#</c> comments)
|
||||
/// sufficient to parse the legacy <c>RIO.ini</c>. Keys are case-insensitive.
|
||||
/// </summary>
|
||||
public sealed class IniFile
|
||||
{
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _sections =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static IniFile Parse(string text)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
var ini = new IniFile();
|
||||
var current = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
ini._sections[string.Empty] = current;
|
||||
|
||||
foreach (string rawLine in text.Split('\n'))
|
||||
{
|
||||
string line = rawLine.Trim();
|
||||
if (line.Length == 0 || line[0] is ';' or '#')
|
||||
continue;
|
||||
|
||||
if (line[0] == '[' && line[^1] == ']')
|
||||
{
|
||||
string section = line[1..^1].Trim();
|
||||
if (!ini._sections.TryGetValue(section, out current!))
|
||||
ini._sections[section] = current = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
continue;
|
||||
}
|
||||
|
||||
int eq = line.IndexOf('=');
|
||||
if (eq < 0)
|
||||
continue;
|
||||
|
||||
string key = line[..eq].Trim();
|
||||
string value = line[(eq + 1)..].Trim();
|
||||
if (key.Length > 0)
|
||||
current[key] = value;
|
||||
}
|
||||
|
||||
return ini;
|
||||
}
|
||||
|
||||
/// <summary>All key/value pairs in a section (empty if the section is absent).</summary>
|
||||
public IReadOnlyDictionary<string, string> Section(string name) =>
|
||||
_sections.TryGetValue(name, out Dictionary<string, string>? s)
|
||||
? s
|
||||
: new Dictionary<string, string>();
|
||||
|
||||
public string? GetString(string section, string key) =>
|
||||
Section(section).TryGetValue(key, out string? v) ? v : null;
|
||||
|
||||
public bool GetBool(string section, string key, bool fallback)
|
||||
{
|
||||
string? v = GetString(section, key);
|
||||
if (v is null)
|
||||
return fallback;
|
||||
return v.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"1" or "true" or "yes" or "on" => true,
|
||||
"0" or "false" or "no" or "off" => false,
|
||||
_ => fallback,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Parse an integer value that may be hex (<c>0x…</c>) or decimal.</summary>
|
||||
public static int ParseInt(string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
string v = value.Trim();
|
||||
bool neg = v.StartsWith('-');
|
||||
if (neg) v = v[1..];
|
||||
|
||||
int magnitude = v.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? Convert.ToInt32(v[2..], 16)
|
||||
: int.Parse(v);
|
||||
|
||||
return neg ? -magnitude : magnitude;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Globalization;
|
||||
using RioJoy.Core.Calibration;
|
||||
using RioJoy.Core.Mapping;
|
||||
|
||||
namespace RioJoy.Core.Profiles;
|
||||
|
||||
/// <summary>
|
||||
/// Imports a legacy <c>RIO.ini</c> into a <see cref="RioProfile"/> (see the key
|
||||
/// layout in <c>riovjoy2.cpp</c> around line 343). The <c>[Buttons]</c> keys are
|
||||
/// <c>RIO<hex addr></c> with hex/decimal map words; <c>[JoyStick]</c> holds
|
||||
/// the invert/enableZR flags; <c>[Plasma] Greeting</c> is the greeting text.
|
||||
/// </summary>
|
||||
public static class RioIniImporter
|
||||
{
|
||||
/// <summary>Parse <paramref name="iniText"/> into a profile named <paramref name="name"/>.</summary>
|
||||
public static RioProfile Import(string iniText, string name = "Imported")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(iniText);
|
||||
IniFile ini = IniFile.Parse(iniText);
|
||||
|
||||
var profile = new RioProfile
|
||||
{
|
||||
Name = name,
|
||||
PlasmaGreeting = ini.GetString("Plasma", "Greeting"),
|
||||
WallpaperPath = ini.GetString("Desktop", "File"),
|
||||
Calibration = new AxisCalibrationConfig
|
||||
{
|
||||
InvertX = ini.GetBool("JoyStick", "invertX", false),
|
||||
InvertY = ini.GetBool("JoyStick", "invertY", false),
|
||||
InvertZ = ini.GetBool("JoyStick", "invertZ", false),
|
||||
InvertXR = ini.GetBool("JoyStick", "invertXR", false),
|
||||
InvertYR = ini.GetBool("JoyStick", "invertYR", false),
|
||||
InvertZR = ini.GetBool("JoyStick", "invertZR", false),
|
||||
EnableZR = ini.GetBool("JoyStick", "enableZR", true),
|
||||
},
|
||||
};
|
||||
|
||||
foreach ((string key, string value) in ini.Section("Buttons"))
|
||||
{
|
||||
if (!TryParseButtonAddress(key, out int address))
|
||||
continue;
|
||||
|
||||
int raw = IniFile.ParseInt(value);
|
||||
if (raw != 0) // skip "do nothing" entries
|
||||
profile.Buttons[address] = raw & 0xFFFF;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
// Keys look like "RIO00".."RIO6F": "RIO" + a hex address into the iRIO table.
|
||||
private static bool TryParseButtonAddress(string key, out int address)
|
||||
{
|
||||
address = 0;
|
||||
if (!key.StartsWith("RIO", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
string hex = key[3..];
|
||||
if (!int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out address))
|
||||
return false;
|
||||
|
||||
return address >= 0 && address < RioAddress.TableSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using RioJoy.Core.Calibration;
|
||||
using RioJoy.Core.Mapping;
|
||||
|
||||
namespace RioJoy.Core.Profiles;
|
||||
|
||||
/// <summary>
|
||||
/// A per-game profile: everything about how the cockpit behaves for one
|
||||
/// non-native game (see docs/PLAN.md §Profiles). The unified profile supersedes
|
||||
/// the legacy single-file <c>RIO.ini</c>. Profiles never describe the native
|
||||
/// games — those are the hands-off/yield case.
|
||||
/// </summary>
|
||||
public sealed class RioProfile
|
||||
{
|
||||
/// <summary>Display name (unique within a library).</summary>
|
||||
public string Name { get; set; } = "Unnamed";
|
||||
|
||||
/// <summary>RIO serial port (e.g. "COM3"); null = use the app default.</summary>
|
||||
public string? RioComPort { get; set; }
|
||||
|
||||
/// <summary>Plasma/VFD serial port; null = use the app default or none.</summary>
|
||||
public string? PlasmaComPort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The <c>iRIO</c> map: RIO address (0x00..0x6F) → 16-bit map word. Only
|
||||
/// mapped addresses need entries.
|
||||
/// </summary>
|
||||
public Dictionary<int, int> Buttons { get; set; } = new();
|
||||
|
||||
/// <summary>Axis calibration / invert options.</summary>
|
||||
public AxisCalibrationConfig Calibration { get; set; } = new();
|
||||
|
||||
/// <summary>Plasma greeting text shown on load (null = leave display as-is).</summary>
|
||||
public string? PlasmaGreeting { get; set; }
|
||||
|
||||
/// <summary>Cockpit wallpaper image path (generated in Phase 7).</summary>
|
||||
public string? WallpaperPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Executable names that select this profile when they are the foreground app
|
||||
/// (matched case-insensitively, with or without ".exe").
|
||||
/// </summary>
|
||||
public List<string> MatchExecutables { get; set; } = new();
|
||||
|
||||
/// <summary>Build a <see cref="RioInputMap"/> from <see cref="Buttons"/>.</summary>
|
||||
public RioInputMap ToInputMap()
|
||||
{
|
||||
var map = new RioInputMap();
|
||||
foreach ((int address, int raw) in Buttons)
|
||||
map[address] = new RioMapEntry((ushort)raw);
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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 bool _started;
|
||||
|
||||
public RioRuntime(
|
||||
RioSerialLink link,
|
||||
RioInputMap map,
|
||||
IInputSink input,
|
||||
IJoystickSink joystick,
|
||||
AxisCalibrator? calibrator = null)
|
||||
{
|
||||
_link = link ?? throw new ArgumentNullException(nameof(link));
|
||||
ArgumentNullException.ThrowIfNull(map);
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
_joystick = joystick ?? throw new ArgumentNullException(nameof(joystick));
|
||||
_calibrator = calibrator ?? new AxisCalibrator();
|
||||
|
||||
var lamp = new SerialLampSink(link);
|
||||
_router = new InputRouter(map, input, joystick, lamp, this);
|
||||
}
|
||||
|
||||
/// <summary>Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate).</summary>
|
||||
public event Action<RioCommandCode>? DiagnosticToggle;
|
||||
|
||||
/// <summary>Subscribe to the link and set all lamps to their idle state.</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_started)
|
||||
return;
|
||||
_started = true;
|
||||
|
||||
_link.PacketReceived += OnPacket;
|
||||
_link.AnalogReceived += OnAnalog;
|
||||
_router.InitializeLamps();
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribe from the link.</summary>
|
||||
public void Stop()
|
||||
{
|
||||
if (!_started)
|
||||
return;
|
||||
_started = false;
|
||||
|
||||
_link.PacketReceived -= OnPacket;
|
||||
_link.AnalogReceived -= OnAnalog;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
private void OnAnalog(AnalogReport report)
|
||||
{
|
||||
AxisOutputs axes = _calibrator.Update(report);
|
||||
_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);
|
||||
}
|
||||
|
||||
private void OnPacket(RioPacket packet)
|
||||
{
|
||||
ReadOnlySpan<byte> p = packet.Payload.Span;
|
||||
switch (packet.Command)
|
||||
{
|
||||
case RioCommand.ButtonPressed when p[0] < RioAddress.ButtonCount:
|
||||
_router.Press(RioAddress.FromButton(p[0]));
|
||||
break;
|
||||
case RioCommand.ButtonReleased when p[0] < RioAddress.ButtonCount:
|
||||
_router.Release(RioAddress.FromButton(p[0]));
|
||||
break;
|
||||
case RioCommand.KeyPressed when IsKeypad(p):
|
||||
_router.Press(RioAddress.FromKeypad(p[0], p[1]));
|
||||
break;
|
||||
case RioCommand.KeyReleased when IsKeypad(p):
|
||||
_router.Release(RioAddress.FromKeypad(p[0], p[1]));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using RioJoy.Core.Mapping;
|
||||
|
||||
namespace RioJoy.Core.Serial;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ILampSink"/> that sends lamp feedback back to the RIO as
|
||||
/// <c>LampRequest</c> packets over the serial link. Writes are fire-and-forget
|
||||
/// (lamp feedback is best-effort and must not block input routing).
|
||||
/// </summary>
|
||||
public sealed class SerialLampSink : ILampSink
|
||||
{
|
||||
private readonly RioSerialLink _link;
|
||||
|
||||
public SerialLampSink(RioSerialLink link)
|
||||
{
|
||||
_link = link ?? throw new ArgumentNullException(nameof(link));
|
||||
}
|
||||
|
||||
public void SetLamp(int address, byte lampState)
|
||||
{
|
||||
_ = SendAsync((byte)address, lampState);
|
||||
}
|
||||
|
||||
private async Task SendAsync(byte address, byte state)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _link.SetLampAsync(address, state).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: a dropped lamp update must not crash the input path.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user