diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..1fa8c99
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,143 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ - codex/rs50-oled-production
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ build-and-test:
+ runs-on: windows-latest
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@v7
+
+ - name: Install .NET 10 SDK
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore
+ run: dotnet restore .\LogiDynamicDash.slnx
+
+ - name: Build with warnings as errors
+ run: >
+ dotnet build .\LogiDynamicDash.slnx
+ -c Release
+ --no-restore
+ -warnaserror
+
+ - name: Test
+ run: >
+ dotnet test .\LogiDynamicDash.slnx
+ -c Release
+ --no-build
+
+ - name: Verify formatting
+ run: >
+ dotnet format .\LogiDynamicDash.slnx
+ --verify-no-changes
+ --no-restore
+
+ - name: Audit RS50 production surface
+ shell: powershell
+ run: .\scripts\Test-Rs50OledProductionSurface.ps1
+
+ - name: Audit vulnerable packages
+ run: >
+ dotnet list .\LogiDynamicDash.slnx
+ package
+ --vulnerable
+ --include-transitive
+
+ package-windows:
+ needs: build-and-test
+ runs-on: windows-latest
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@v7
+
+ - name: Install .NET 10 SDK
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore Windows runtime
+ run: |
+ dotnet restore .\LogiDynamicDash\LogiDynamicDash.csproj -r win-x64
+ dotnet restore .\LogiDynamicDash.Configurator\LogiDynamicDash.Configurator.csproj -r win-x64
+
+ - name: Publish framework-dependent Windows package
+ run: >
+ dotnet publish .\LogiDynamicDash\LogiDynamicDash.csproj
+ -c Release
+ -r win-x64
+ --self-contained false
+ --no-restore
+ -o .\artifacts\LogiDynamicDash-win-x64
+
+ - name: Publish framework-dependent configurator
+ run: >
+ dotnet publish
+ .\LogiDynamicDash.Configurator\LogiDynamicDash.Configurator.csproj
+ -c Release
+ -r win-x64
+ --self-contained false
+ --no-restore
+ -o .\artifacts\LogiDynamicDash-win-x64
+
+ - name: Complete and smoke-test framework-dependent package
+ shell: powershell
+ run: >
+ .\scripts\Complete-WindowsPackage.ps1
+ -PackageRoot .\artifacts\LogiDynamicDash-win-x64
+ -Version 0.3.0-alpha
+
+ - name: Upload Windows package
+ uses: actions/upload-artifact@v7
+ with:
+ name: LogiDynamicDash-win-x64
+ path: artifacts/LogiDynamicDash-win-x64
+ if-no-files-found: error
+ retention-days: 14
+
+ - name: Publish self-contained Windows package
+ run: >
+ dotnet publish .\LogiDynamicDash\LogiDynamicDash.csproj
+ -c Release
+ -r win-x64
+ --self-contained true
+ --no-restore
+ -o .\artifacts\LogiDynamicDash-win-x64-self-contained
+
+ - name: Publish self-contained configurator
+ run: >
+ dotnet publish
+ .\LogiDynamicDash.Configurator\LogiDynamicDash.Configurator.csproj
+ -c Release
+ -r win-x64
+ --self-contained true
+ --no-restore
+ -o .\artifacts\LogiDynamicDash-win-x64-self-contained
+
+ - name: Complete and smoke-test self-contained package
+ shell: powershell
+ run: >
+ .\scripts\Complete-WindowsPackage.ps1
+ -PackageRoot .\artifacts\LogiDynamicDash-win-x64-self-contained
+ -Version 0.3.0-alpha
+
+ - name: Upload self-contained Windows package
+ uses: actions/upload-artifact@v7
+ with:
+ name: LogiDynamicDash-win-x64-self-contained
+ path: artifacts/LogiDynamicDash-win-x64-self-contained
+ if-no-files-found: error
+ retention-days: 14
diff --git a/.gitignore b/.gitignore
index 93df31b..66ae82b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -379,6 +379,14 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
+# Local hardware-research artifacts
+.tmp/
+.logs/
+*.pcap
+*.pcapng
+*.mov
+*.MOV
+
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
diff --git a/LogiDynamicDash.Configurator/App.xaml b/LogiDynamicDash.Configurator/App.xaml
new file mode 100644
index 0000000..cba0c76
--- /dev/null
+++ b/LogiDynamicDash.Configurator/App.xaml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/LogiDynamicDash.Configurator/App.xaml.cs b/LogiDynamicDash.Configurator/App.xaml.cs
new file mode 100644
index 0000000..9a6c03b
--- /dev/null
+++ b/LogiDynamicDash.Configurator/App.xaml.cs
@@ -0,0 +1,7 @@
+using System.Windows;
+
+namespace LogiDynamicDash.Configurator;
+
+public partial class App : Application
+{
+}
diff --git a/LogiDynamicDash.Configurator/LogiDynamicDash.Configurator.csproj b/LogiDynamicDash.Configurator/LogiDynamicDash.Configurator.csproj
new file mode 100644
index 0000000..d803c01
--- /dev/null
+++ b/LogiDynamicDash.Configurator/LogiDynamicDash.Configurator.csproj
@@ -0,0 +1,17 @@
+
+
+
+ WinExe
+ net10.0-windows
+ true
+ enable
+ enable
+ LogiDynamicDash.Configurator
+ 0.3.0-alpha
+
+
+
+
+
+
+
diff --git a/LogiDynamicDash.Configurator/MainWindow.xaml b/LogiDynamicDash.Configurator/MainWindow.xaml
new file mode 100644
index 0000000..b6354a4
--- /dev/null
+++ b/LogiDynamicDash.Configurator/MainWindow.xaml
@@ -0,0 +1,389 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LogiDynamicDash.Configurator/MainWindow.xaml.cs b/LogiDynamicDash.Configurator/MainWindow.xaml.cs
new file mode 100644
index 0000000..5eb37dc
--- /dev/null
+++ b/LogiDynamicDash.Configurator/MainWindow.xaml.cs
@@ -0,0 +1,781 @@
+using System.Globalization;
+using System.IO;
+using System.Text.Json;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using LogiDynamicDash.Configuration;
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Offline;
+using LogiDynamicDash.Runtime;
+using Microsoft.Win32;
+
+namespace LogiDynamicDash.Configurator;
+
+public partial class MainWindow : Window
+{
+ private readonly ComboBox[] layoutBoxes;
+ private bool updating;
+ private IRacingSessionIdentity? detectedIdentity;
+ private CancellationTokenSource? runtimeCancellation;
+ private Task? runtimeTask;
+ private int? liveCarId;
+ private IRacingDiscipline liveDiscipline = IRacingDiscipline.Unknown;
+
+ private static readonly string ApplicationDataDirectory = Path.Combine(
+ Environment.GetFolderPath(
+ Environment.SpecialFolder.LocalApplicationData),
+ "LogiDynamicDash");
+ private static readonly string ActiveConfigurationPath = Path.Combine(
+ ApplicationDataDirectory,
+ "active-dashboard.json");
+ private static readonly string PreferencesPath = Path.Combine(
+ ApplicationDataDirectory,
+ "preferences.json");
+ private static readonly string ProfileDirectory = Path.Combine(
+ ApplicationDataDirectory,
+ "profiles");
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ layoutBoxes =
+ [
+ NormalLayoutBox,
+ BrakeBiasLayoutBox,
+ LastLapLayoutBox,
+ ConnectionLayoutBox
+ ];
+
+ DisciplineBox.ItemsSource =
+ DisciplineProfileRecommendations.SupportedDisciplines;
+ SpeedUnitBox.ItemsSource = Enum.GetValues();
+ PreviewModeBox.ItemsSource = Enum.GetValues();
+ foreach (ComboBox box in layoutBoxes)
+ {
+ box.ItemsSource = Enum.GetValues();
+ box.SelectionChanged += (_, _) => UpdatePreview();
+ }
+
+ DisciplineBox.SelectionChanged += (_, _) => UpdateRecommendation();
+ SpeedUnitBox.SelectionChanged += (_, _) => UpdateRecommendation();
+ MaximumRpmBox.TextChanged += (_, _) => UpdatePreview();
+ GaugeSpeedBox.TextChanged += (_, _) => UpdatePreview();
+ PreviewModeBox.SelectionChanged += (_, _) => UpdatePreview();
+
+ DisciplineBox.SelectedItem = IRacingDiscipline.SportsCar;
+ SpeedUnitBox.SelectedItem = SpeedUnit.KilometersPerHour;
+ PreviewModeBox.SelectedItem = DisplayMode.Normal;
+ ApplyRecommendation(IRacingDiscipline.SportsCar);
+ LoadPersistedSettings();
+ Closing += (_, _) =>
+ {
+ runtimeCancellation?.Cancel();
+ PersistSettingsBestEffort();
+ };
+ }
+
+ private void StartDashboard_Click(object sender, RoutedEventArgs e)
+ {
+ if (runtimeTask is { IsCompleted: false })
+ {
+ return;
+ }
+
+ try
+ {
+ Rs50OledConfiguration configuration = CreateConfiguration();
+ double lastLapSeconds = ParseLastLapSeconds();
+ Directory.CreateDirectory(ApplicationDataDirectory);
+ File.WriteAllText(
+ ActiveConfigurationPath,
+ Rs50OledConfigurationFile.Serialize(configuration));
+ SavePreferences();
+
+ runtimeCancellation = new CancellationTokenSource();
+ DashboardRuntime runtime = new();
+ DashboardRuntimeSettings settings = new(
+ configuration,
+ ProfileDirectory,
+ AutomaticProfilesBox.IsChecked == true,
+ lastLapSeconds);
+ StartDashboardButton.IsEnabled = false;
+ StopDashboardButton.IsEnabled = true;
+ RuntimeStateText.Text = "Starting dashboard";
+ RuntimeDetailText.Text =
+ "Waiting for the validated RS50 and iRacing.";
+ runtimeTask = RunDashboardAsync(
+ runtime,
+ settings,
+ runtimeCancellation);
+ }
+ catch (Exception exception)
+ {
+ ShowRuntimeError(exception);
+ }
+ }
+
+ private async Task RunDashboardAsync(
+ DashboardRuntime runtime,
+ DashboardRuntimeSettings settings,
+ CancellationTokenSource cancellation)
+ {
+ try
+ {
+ await runtime.RunAsync(
+ settings,
+ status => Dispatcher.BeginInvoke(
+ () => ApplyRuntimeStatus(status)),
+ cancellation.Token);
+ }
+ catch (Exception exception)
+ {
+ await Dispatcher.BeginInvoke(
+ () => ShowRuntimeError(exception));
+ }
+ finally
+ {
+ await Dispatcher.BeginInvoke(
+ () =>
+ {
+ if (ReferenceEquals(runtimeCancellation, cancellation))
+ {
+ runtimeCancellation.Dispose();
+ runtimeCancellation = null;
+ }
+
+ StartDashboardButton.IsEnabled = true;
+ StopDashboardButton.IsEnabled = false;
+ RuntimeStateText.Text = "Dashboard stopped";
+ });
+ }
+ }
+
+ private void StopDashboard_Click(object sender, RoutedEventArgs e)
+ {
+ StopDashboardButton.IsEnabled = false;
+ RuntimeStateText.Text = "Stopping dashboard";
+ runtimeCancellation?.Cancel();
+ }
+
+ private void ApplyRuntimeStatus(DashboardRuntimeStatus status)
+ {
+ RuntimeStateText.Text = status.Message;
+ RuntimeDetailText.Text =
+ $"OLED: {status.Oled} · iRacing: {status.Telemetry} · " +
+ $"Car: {status.Car} · Category: " +
+ IRacingDisciplineDisplay.Name(status.Discipline);
+ liveCarId = status.CarId;
+ liveDiscipline = status.Discipline;
+ SaveCarProfileButton.IsEnabled =
+ status.CarId is > 0 || detectedIdentity?.Car?.CarId is > 0;
+ }
+
+ private void ApplyRecommendation_Click(object sender, RoutedEventArgs e)
+ {
+ if (DisciplineBox.SelectedItem is IRacingDiscipline discipline)
+ {
+ ApplyRecommendation(discipline);
+ }
+ }
+
+ private void ApplyRecommendation(IRacingDiscipline discipline)
+ {
+ SpeedUnit unit = SpeedUnitBox.SelectedItem is SpeedUnit selected
+ ? selected
+ : SpeedUnit.KilometersPerHour;
+ DisciplineProfileRecommendation recommendation =
+ DisciplineProfileRecommendations.Create(discipline, unit);
+ LoadConfiguration(recommendation.Configuration);
+ RecommendationText.Text = recommendation.Summary;
+ StatusText.Text = $"{discipline} recommendation applied.";
+ }
+
+ private void UpdateRecommendation()
+ {
+ if (updating ||
+ DisciplineBox.SelectedItem is not IRacingDiscipline discipline ||
+ SpeedUnitBox.SelectedItem is not SpeedUnit unit)
+ {
+ return;
+ }
+
+ RecommendationText.Text =
+ DisciplineProfileRecommendations.Create(discipline, unit).Summary;
+ UpdateDetectedIdentity();
+ UpdatePreview();
+ }
+
+ private void InspectReplay_Click(object sender, RoutedEventArgs e)
+ {
+ OpenFileDialog dialog = new()
+ {
+ Filter = "LogiDynamicDash telemetry replay (*.json)|*.json",
+ CheckFileExists = true
+ };
+ if (dialog.ShowDialog(this) != true)
+ {
+ return;
+ }
+
+ try
+ {
+ IReadOnlyList events =
+ TelemetryReplayFile.Load(dialog.FileName);
+ detectedIdentity = events
+ .Select(replayEvent => replayEvent.SessionIdentity)
+ .LastOrDefault(identity => identity is not null);
+ if (detectedIdentity is null)
+ {
+ throw new InvalidDataException(
+ "This replay contains no schema 2 session identity.");
+ }
+
+ UpdateDetectedIdentity();
+ StatusText.Text =
+ $"Inspected {Path.GetFileName(dialog.FileName)}.";
+ }
+ catch (Exception exception)
+ {
+ detectedIdentity = null;
+ UpdateDetectedIdentity();
+ MessageBox.Show(
+ this,
+ exception.Message,
+ "Replay identity unavailable",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ }
+ }
+
+ private void ApplyDetected_Click(object sender, RoutedEventArgs e)
+ {
+ if (detectedIdentity is null ||
+ SpeedUnitBox.SelectedItem is not SpeedUnit unit)
+ {
+ return;
+ }
+
+ SessionProfileResolution resolution =
+ SessionProfileResolver.Resolve(detectedIdentity, unit);
+ if (resolution.Recommendation is null)
+ {
+ return;
+ }
+
+ DisciplineBox.SelectedItem = detectedIdentity.Discipline;
+ ApplyRecommendation(detectedIdentity.Discipline);
+ StatusText.Text =
+ $"Applied {IRacingDisciplineDisplay.Name(
+ detectedIdentity.Discipline)} for CarID " +
+ $"{resolution.CarKey!.CarId}.";
+ }
+
+ private void UpdateDetectedIdentity()
+ {
+ if (detectedIdentity is null ||
+ SpeedUnitBox.SelectedItem is not SpeedUnit unit)
+ {
+ DetectedIdentityText.Text = "No session identity loaded.";
+ ApplyDetectedButton.IsEnabled = false;
+ return;
+ }
+
+ SessionProfileResolution resolution =
+ SessionProfileResolver.Resolve(detectedIdentity, unit);
+ CarIdentity? car = detectedIdentity.Car;
+ string carName = !string.IsNullOrWhiteSpace(car?.DisplayName)
+ ? car.DisplayName
+ : "Unknown car";
+ string carId = car?.CarId?.ToString(CultureInfo.InvariantCulture)
+ ?? "missing";
+ DetectedIdentityText.Text =
+ $"Car: {carName} (CarID {carId}){Environment.NewLine}" +
+ $"Class: {car?.CarClassShortName ?? "Unknown"}{Environment.NewLine}" +
+ $"Category: " +
+ $"{IRacingDisciplineDisplay.Name(detectedIdentity.Discipline)}" +
+ $"{Environment.NewLine}Track type: " +
+ $"{detectedIdentity.TrackType}{Environment.NewLine}" +
+ $"Decision: {resolution.Explanation}";
+ ApplyDetectedButton.IsEnabled = resolution.CanApply;
+ SaveCarProfileButton.IsEnabled =
+ detectedIdentity.Car?.CarId is > 0 || liveCarId is > 0;
+ }
+
+ private void SaveCategoryProfile_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ IRacingDiscipline discipline =
+ DisciplineBox.SelectedItem is IRacingDiscipline selected
+ ? selected
+ : liveDiscipline;
+ string path = new Rs50ProfileStore(ProfileDirectory)
+ .SaveForDiscipline(discipline, CreateConfiguration());
+ StatusText.Text =
+ $"Saved category profile {Path.GetFileName(path)}.";
+ }
+ catch (Exception exception)
+ {
+ ShowConfigurationError(
+ exception,
+ "Category profile not saved");
+ }
+ }
+
+ private void SaveCarProfile_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ int? carId = liveCarId ?? detectedIdentity?.Car?.CarId;
+ if (carId is not > 0)
+ {
+ throw new InvalidOperationException(
+ "Run the dashboard or inspect a replay with an exact " +
+ "CarID first.");
+ }
+
+ string path = new Rs50ProfileStore(ProfileDirectory)
+ .SaveForCar(carId.Value, CreateConfiguration());
+ StatusText.Text =
+ $"Saved CarID {carId} profile as {Path.GetFileName(path)}.";
+ }
+ catch (Exception exception)
+ {
+ ShowConfigurationError(exception, "Car profile not saved");
+ }
+ }
+
+ private void LoadConfiguration(Rs50OledConfiguration configuration)
+ {
+ updating = true;
+ try
+ {
+ SpeedUnitBox.SelectedItem = configuration.SpeedUnit;
+ MaximumRpmBox.Text =
+ configuration.MaximumRpm.ToString(CultureInfo.InvariantCulture);
+ GaugeSpeedBox.Text = configuration.GaugeMaximumSpeed.ToString(
+ CultureInfo.InvariantCulture);
+ NormalLayoutBox.SelectedItem =
+ configuration.LayoutFor(DisplayMode.Normal);
+ BrakeBiasLayoutBox.SelectedItem =
+ configuration.LayoutFor(DisplayMode.BrakeBias);
+ LastLapLayoutBox.SelectedItem =
+ configuration.LayoutFor(DisplayMode.LastLap);
+ ConnectionLayoutBox.SelectedItem =
+ configuration.LayoutFor(DisplayMode.ConnectionProblem);
+ }
+ finally
+ {
+ updating = false;
+ }
+
+ UpdateDetectedIdentity();
+ UpdatePreview();
+ }
+
+ private Rs50OledConfiguration CreateConfiguration()
+ {
+ if (SpeedUnitBox.SelectedItem is not SpeedUnit speedUnit ||
+ layoutBoxes.Any(box => box.SelectedItem is not Rs50OledLayout) ||
+ !double.TryParse(
+ MaximumRpmBox.Text,
+ NumberStyles.Float,
+ CultureInfo.InvariantCulture,
+ out double maximumRpm) ||
+ !double.TryParse(
+ GaugeSpeedBox.Text,
+ NumberStyles.Float,
+ CultureInfo.InvariantCulture,
+ out double maximumSpeed))
+ {
+ throw new InvalidDataException(
+ "Complete every field using invariant numeric values.");
+ }
+
+ return new Rs50OledConfiguration(
+ new Dictionary
+ {
+ [DisplayMode.Normal] =
+ (Rs50OledLayout)NormalLayoutBox.SelectedItem,
+ [DisplayMode.BrakeBias] =
+ (Rs50OledLayout)BrakeBiasLayoutBox.SelectedItem,
+ [DisplayMode.LastLap] =
+ (Rs50OledLayout)LastLapLayoutBox.SelectedItem,
+ [DisplayMode.ConnectionProblem] =
+ (Rs50OledLayout)ConnectionLayoutBox.SelectedItem
+ },
+ speedUnit,
+ maximumRpm,
+ maximumSpeed);
+ }
+
+ private double ParseLastLapSeconds()
+ {
+ if (!double.TryParse(
+ LastLapSecondsBox.Text,
+ NumberStyles.Float,
+ CultureInfo.InvariantCulture,
+ out double seconds) ||
+ !double.IsFinite(seconds) ||
+ seconds is < 1 or > 15)
+ {
+ throw new InvalidDataException(
+ "Last-lap display duration must be between 1 and 15 seconds.");
+ }
+
+ return seconds;
+ }
+
+ private void UpdatePreview()
+ {
+ if (updating)
+ {
+ return;
+ }
+
+ try
+ {
+ Rs50OledConfiguration configuration = CreateConfiguration();
+ Rs50TelemetryFrameFormatter formatter = new(configuration);
+ TelemetrySnapshot sample = new()
+ {
+ ConnectionState = "ERROR",
+ IsOnTrack = true,
+ Gear = 3,
+ Rpm = 6500,
+ SpeedMetersPerSecond = 123f / 3.6f,
+ BrakeBiasPercent = 52.3f,
+ LastLapTimeSeconds = 92.481f
+ };
+ DisplayMode previewMode =
+ PreviewModeBox.SelectedItem is DisplayMode selected
+ ? selected
+ : DisplayMode.Normal;
+ Rs50OledFrame frame = formatter.Format(sample, previewMode);
+ PreviewText.Text =
+ $"{previewMode} [{configuration.LayoutFor(previewMode)}] " +
+ Rs50OledFrameDescription.Describe(frame);
+ RenderOledPreview(frame);
+ StatusText.Text = "Configuration is valid.";
+ }
+ catch (Exception exception)
+ {
+ PreviewText.Text = "Preview unavailable.";
+ StatusText.Text = exception.Message;
+ }
+ }
+
+ private void Open_Click(object sender, RoutedEventArgs e)
+ {
+ OpenFileDialog dialog = new()
+ {
+ Filter = "LogiDynamicDash configuration (*.json)|*.json",
+ CheckFileExists = true
+ };
+ if (dialog.ShowDialog(this) != true)
+ {
+ return;
+ }
+
+ try
+ {
+ LoadConfiguration(Rs50OledConfigurationFile.Load(dialog.FileName));
+ StatusText.Text = $"Opened {Path.GetFileName(dialog.FileName)}.";
+ }
+ catch (Exception exception)
+ {
+ MessageBox.Show(
+ this,
+ exception.Message,
+ "Configuration rejected",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+ }
+
+ private void Save_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ Rs50OledConfiguration configuration = CreateConfiguration();
+ SaveFileDialog dialog = new()
+ {
+ Filter = "LogiDynamicDash configuration (*.json)|*.json",
+ DefaultExt = ".json",
+ AddExtension = true,
+ FileName = "logidynamicdash.json"
+ };
+ if (dialog.ShowDialog(this) != true)
+ {
+ return;
+ }
+
+ File.WriteAllText(
+ dialog.FileName,
+ Rs50OledConfigurationFile.Serialize(configuration));
+ StatusText.Text = $"Saved {Path.GetFileName(dialog.FileName)}.";
+ }
+ catch (Exception exception)
+ {
+ MessageBox.Show(
+ this,
+ exception.Message,
+ "Configuration not saved",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+ }
+
+ private void LoadPersistedSettings()
+ {
+ try
+ {
+ if (File.Exists(ActiveConfigurationPath))
+ {
+ LoadConfiguration(
+ Rs50OledConfigurationFile.Load(
+ ActiveConfigurationPath));
+ }
+
+ if (!File.Exists(PreferencesPath))
+ {
+ return;
+ }
+
+ using JsonDocument document = JsonDocument.Parse(
+ File.ReadAllText(PreferencesPath));
+ JsonElement root = document.RootElement;
+ if (root.TryGetProperty("automaticProfiles", out var automatic) &&
+ automatic.ValueKind is JsonValueKind.True or
+ JsonValueKind.False)
+ {
+ AutomaticProfilesBox.IsChecked = automatic.GetBoolean();
+ }
+
+ if (root.TryGetProperty("lastLapDisplaySeconds", out var duration) &&
+ duration.TryGetDouble(out double seconds) &&
+ double.IsFinite(seconds) &&
+ seconds is >= 1 and <= 15)
+ {
+ LastLapSecondsBox.Text =
+ seconds.ToString(CultureInfo.InvariantCulture);
+ }
+ }
+ catch (Exception exception)
+ {
+ StatusText.Text =
+ $"Saved settings were ignored: {exception.Message}";
+ }
+ }
+
+ private void RenderOledPreview(Rs50OledFrame frame)
+ {
+ OledPreviewSurface.Children.Clear();
+ OledPreviewSurface.RowDefinitions.Clear();
+
+ switch (frame)
+ {
+ case Rs50LayoutAFrame:
+ AddCenteredText("LAYOUT A", 22);
+ AddCenteredText("NO DATA FIELDS", 12, secondary: true);
+ break;
+ case Rs50LayoutBFrame:
+ AddCenteredText("LAYOUT B", 22);
+ AddCenteredText("NO DATA FIELDS", 12, secondary: true);
+ break;
+ case Rs50LayoutCFrame layout:
+ AddCenteredText("RPM", 12, secondary: true);
+ AddGauge(layout.MainGauge, 28);
+ break;
+ case Rs50LayoutDFrame layout:
+ AddCenteredText(layout.Text, 28);
+ AddGauge(layout.MainGauge, 24);
+ AddGauge(layout.ThinIndicator, 7);
+ break;
+ case Rs50LayoutEFrame layout:
+ AddDualText(layout.LeftText, layout.RightText, 30);
+ AddGauge(layout.MainGauge, 24);
+ AddGauge(layout.ThinIndicator, 7);
+ break;
+ case Rs50LayoutFFrame layout:
+ AddDualText(layout.LeftText, layout.RightText, 42);
+ break;
+ case Rs50LayoutGFrame layout:
+ AddDualText(layout.LeftText, layout.RightText, 32);
+ break;
+ case Rs50LayoutHFrame layout:
+ AddCenteredText(layout.TopText, 19, secondary: true);
+ AddCenteredText(layout.BottomText, 34);
+ break;
+ case Rs50LayoutIFrame layout:
+ AddFourRows(
+ layout.Line1,
+ layout.Line2,
+ layout.Line3,
+ layout.Line4,
+ 20);
+ break;
+ case Rs50LayoutJFrame layout:
+ AddFourRows(
+ layout.Line1,
+ layout.Line2,
+ layout.Line3,
+ layout.Line4,
+ 23);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(frame));
+ }
+ }
+
+ private void AddCenteredText(
+ string text,
+ double size,
+ bool secondary = false)
+ {
+ int row = AddPreviewRow();
+ TextBlock block = PreviewTextBlock(text, size, secondary);
+ block.HorizontalAlignment = HorizontalAlignment.Center;
+ Grid.SetRow(block, row);
+ OledPreviewSurface.Children.Add(block);
+ }
+
+ private void AddDualText(string left, string right, double size)
+ {
+ int row = AddPreviewRow();
+ Grid line = new();
+ line.ColumnDefinitions.Add(new ColumnDefinition());
+ line.ColumnDefinitions.Add(new ColumnDefinition());
+ TextBlock leftBlock = PreviewTextBlock(left, size);
+ TextBlock rightBlock = PreviewTextBlock(right, size);
+ rightBlock.HorizontalAlignment = HorizontalAlignment.Right;
+ Grid.SetColumn(rightBlock, 1);
+ line.Children.Add(leftBlock);
+ line.Children.Add(rightBlock);
+ Grid.SetRow(line, row);
+ OledPreviewSurface.Children.Add(line);
+ }
+
+ private void AddGauge(Rs50GaugeLevel gauge, double height)
+ {
+ int row = AddPreviewRow();
+ ProgressBar bar = new()
+ {
+ Minimum = 0,
+ Maximum = byte.MaxValue,
+ Value = gauge.WireValue,
+ Height = height,
+ Margin = new Thickness(0, 5, 0, 2),
+ Foreground = OledBlue(),
+ Background = new SolidColorBrush(Color.FromRgb(20, 43, 52))
+ };
+ Grid.SetRow(bar, row);
+ OledPreviewSurface.Children.Add(bar);
+ }
+
+ private void AddFourRows(
+ string line1,
+ string line2,
+ string line3,
+ string line4,
+ double size)
+ {
+ string[] lines = [line1, line2, line3, line4];
+ foreach ((string line, int index) in lines.Select(
+ (line, index) => (line, index)))
+ {
+ AddCenteredText(
+ string.IsNullOrEmpty(line) ? " " : line,
+ index % 2 == 0 ? size * 0.72 : size,
+ secondary: index % 2 == 0);
+ }
+ }
+
+ private int AddPreviewRow()
+ {
+ int row = OledPreviewSurface.RowDefinitions.Count;
+ OledPreviewSurface.RowDefinitions.Add(
+ new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
+ return row;
+ }
+
+ private static TextBlock PreviewTextBlock(
+ string text,
+ double size,
+ bool secondary = false) =>
+ new()
+ {
+ Text = text,
+ FontFamily = new FontFamily("Consolas"),
+ FontSize = size,
+ FontWeight = secondary ? FontWeights.Normal : FontWeights.SemiBold,
+ Foreground = secondary
+ ? new SolidColorBrush(Color.FromRgb(142, 221, 234))
+ : OledBlue(),
+ VerticalAlignment = VerticalAlignment.Center,
+ TextTrimming = TextTrimming.CharacterEllipsis
+ };
+
+ private static SolidColorBrush OledBlue() =>
+ new(Color.FromRgb(93, 235, 255));
+
+ private void SavePreferences()
+ {
+ Directory.CreateDirectory(ApplicationDataDirectory);
+ File.WriteAllText(
+ PreferencesPath,
+ JsonSerializer.Serialize(
+ new
+ {
+ schemaVersion = 1,
+ automaticProfiles =
+ AutomaticProfilesBox.IsChecked == true,
+ lastLapDisplaySeconds = ParseLastLapSeconds()
+ },
+ new JsonSerializerOptions { WriteIndented = true }) +
+ Environment.NewLine);
+ }
+
+ private void PersistSettingsBestEffort()
+ {
+ try
+ {
+ Directory.CreateDirectory(ApplicationDataDirectory);
+ File.WriteAllText(
+ ActiveConfigurationPath,
+ Rs50OledConfigurationFile.Serialize(
+ CreateConfiguration()));
+ SavePreferences();
+ }
+ catch
+ {
+ // Closing must not be blocked by an invalid draft field.
+ }
+ }
+
+ private void ShowRuntimeError(Exception exception)
+ {
+ RuntimeStateText.Text = "Dashboard stopped safely";
+ RuntimeDetailText.Text = exception.Message;
+ StartDashboardButton.IsEnabled = true;
+ StopDashboardButton.IsEnabled = false;
+ MessageBox.Show(
+ this,
+ exception.Message,
+ "Dashboard stopped safely",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+
+ private void ShowConfigurationError(
+ Exception exception,
+ string title) =>
+ MessageBox.Show(
+ this,
+ exception.Message,
+ title,
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+}
diff --git a/LogiDynamicDash.Tests/ApplicationDisplayFactoryTests.cs b/LogiDynamicDash.Tests/ApplicationDisplayFactoryTests.cs
new file mode 100644
index 0000000..f3a5596
--- /dev/null
+++ b/LogiDynamicDash.Tests/ApplicationDisplayFactoryTests.cs
@@ -0,0 +1,233 @@
+using LogiDynamicDash.Configuration;
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class ApplicationDisplayFactoryTests
+{
+ [Fact]
+ public void NoArguments_SelectsConsoleWithoutConstructingSession()
+ {
+ int sessionFactoryCalls = 0;
+
+ bool accepted = ApplicationDisplayFactory.TryCreate(
+ [],
+ () =>
+ {
+ sessionFactoryCalls++;
+ return new FakeSession();
+ },
+ out ApplicationDisplaySelection? selection);
+
+ Assert.True(accepted);
+ Assert.NotNull(selection);
+ Assert.False(selection.UsesPhysicalHardware);
+ Assert.False(selection.IsBoundedHardwareTrial);
+ Assert.Equal(0, sessionFactoryCalls);
+ }
+
+ [Fact]
+ public void DrivingArguments_SelectUnboundedPhysicalSafetyEnvelope()
+ {
+ FakeSession session = new();
+ Assert.True(
+ ApplicationDisplayFactory.TryCreate(
+ Rs50DrivingTrialOptionsTests.ValidArguments(),
+ () => session,
+ () => new FakeDisplay(),
+ _ => new Rs50OledConfiguration(Rs50OledLayout.E),
+ out ApplicationDisplaySelection? selection));
+
+ Assert.NotNull(selection);
+ Assert.True(selection.UsesPhysicalHardware);
+ Assert.False(selection.IsBoundedHardwareTrial);
+ Assert.Null(selection.HardwareTrialDuration);
+ selection.Display.Initialize();
+
+ selection.Display.Render(
+ new TelemetrySnapshot
+ {
+ ConnectionState = "CONNECTED",
+ IsOnTrack = true,
+ SpeedMetersPerSecond = 120,
+ Gear = 6,
+ Rpm = 7000
+ },
+ DisplayMode.Normal);
+
+ selection.Display.Stop();
+ Assert.True(session.Disposed);
+ }
+
+ [Fact]
+ public void InvalidArguments_DoNotConstructSession()
+ {
+ int sessionFactoryCalls = 0;
+
+ bool accepted = ApplicationDisplayFactory.TryCreate(
+ ["--enable-rs50-oled"],
+ () =>
+ {
+ sessionFactoryCalls++;
+ return new FakeSession();
+ },
+ out ApplicationDisplaySelection? selection);
+
+ Assert.False(accepted);
+ Assert.Null(selection);
+ Assert.Equal(0, sessionFactoryCalls);
+ }
+
+ [Fact]
+ public void ValidArguments_DeferSessionConstructionUntilInitialization()
+ {
+ int sessionFactoryCalls = 0;
+ int configurationLoaderCalls = 0;
+ FakeSession session = new();
+ Assert.True(
+ ApplicationDisplayFactory.TryCreate(
+ Rs50StationaryTrialOptionsTests.ValidArguments(),
+ () =>
+ {
+ sessionFactoryCalls++;
+ return session;
+ },
+ () => new FakeDisplay(),
+ _ =>
+ {
+ configurationLoaderCalls++;
+ return new Rs50OledConfiguration(Rs50OledLayout.E);
+ },
+ out ApplicationDisplaySelection? selection));
+
+ Assert.NotNull(selection);
+ Assert.True(selection.IsBoundedHardwareTrial);
+ Assert.Equal(
+ Rs50StationaryTrialOptions.Duration,
+ selection.HardwareTrialDuration);
+ Assert.Equal(1, configurationLoaderCalls);
+ Assert.Equal(0, sessionFactoryCalls);
+
+ selection.Display.Initialize();
+ Assert.Equal(1, sessionFactoryCalls);
+ Assert.Equal(1, session.OpenCount);
+ selection.Display.Stop();
+ Assert.True(session.Disposed);
+ }
+
+ [Fact]
+ public void LowSpeedArguments_SelectDistinctBoundedSafetyEnvelope()
+ {
+ FakeSession session = new();
+ Assert.True(
+ ApplicationDisplayFactory.TryCreate(
+ Rs50LowSpeedTrialOptionsTests.ValidArguments(),
+ () => session,
+ () => new FakeDisplay(),
+ _ => new Rs50OledConfiguration(Rs50OledLayout.E),
+ out ApplicationDisplaySelection? selection));
+
+ Assert.NotNull(selection);
+ Assert.Equal(
+ Rs50LowSpeedTrialOptions.Duration,
+ selection.HardwareTrialDuration);
+ selection.Display.Initialize();
+
+ selection.Display.Render(
+ new TelemetrySnapshot
+ {
+ ConnectionState = "CONNECTED",
+ IsOnTrack = true,
+ SpeedMetersPerSecond = 5,
+ Gear = 1,
+ Rpm = 1500
+ },
+ DisplayMode.Normal);
+
+ Assert.Throws(
+ () => selection.Display.Render(
+ new TelemetrySnapshot
+ {
+ ConnectionState = "CONNECTED",
+ IsOnTrack = true,
+ SpeedMetersPerSecond = 6,
+ Gear = 1,
+ Rpm = 1500
+ },
+ DisplayMode.Normal));
+ selection.Display.Stop();
+ }
+
+ [Fact]
+ public void ConfigurationFailure_DoesNotConstructPhysicalSession()
+ {
+ int sessionFactoryCalls = 0;
+
+ Assert.Throws(
+ () => ApplicationDisplayFactory.TryCreate(
+ Rs50StationaryTrialOptionsTests.ValidArguments(),
+ () =>
+ {
+ sessionFactoryCalls++;
+ return new FakeSession();
+ },
+ () => new FakeDisplay(),
+ _ => throw new InvalidDataException("invalid config"),
+ out _));
+
+ Assert.Equal(0, sessionFactoryCalls);
+ }
+
+ [Fact]
+ public void RedirectedConsole_DoesNotBlockStationarySessionInitialization()
+ {
+ FakeSession session = new();
+ Assert.True(
+ ApplicationDisplayFactory.TryCreate(
+ Rs50StationaryTrialOptionsTests.ValidArguments(),
+ () => session,
+ () => new ConsoleDashboard(interactiveOverride: false),
+ _ => new Rs50OledConfiguration(Rs50OledLayout.E),
+ out ApplicationDisplaySelection? selection));
+
+ selection!.Display.Initialize();
+
+ Assert.Equal(1, session.OpenCount);
+ selection.Display.Stop();
+ Assert.True(session.Disposed);
+ }
+
+ private sealed class FakeSession : IRs50OledSession
+ {
+ public int OpenCount { get; private set; }
+
+ public bool Disposed { get; private set; }
+
+ public void Open() =>
+ OpenCount++;
+
+ public Rs50OledSendResult Send(Rs50OledFrame frame) =>
+ Rs50OledSendResult.Transmitted;
+
+ public void Dispose() =>
+ Disposed = true;
+ }
+
+ private sealed class FakeDisplay
+ : LogiDynamicDash.Displays.IApplicationDisplay
+ {
+ public void Initialize()
+ {
+ }
+
+ public void Render(TelemetrySnapshot snapshot, DisplayMode mode)
+ {
+ }
+
+ public void Stop()
+ {
+ }
+ }
+}
diff --git a/LogiDynamicDash.Tests/CompositeApplicationDisplayTests.cs b/LogiDynamicDash.Tests/CompositeApplicationDisplayTests.cs
new file mode 100644
index 0000000..cd38cbf
--- /dev/null
+++ b/LogiDynamicDash.Tests/CompositeApplicationDisplayTests.cs
@@ -0,0 +1,96 @@
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class CompositeApplicationDisplayTests
+{
+ [Fact]
+ public void InitializeFailure_StopsEarlierDisplaysAndPreservesFailure()
+ {
+ FakeDisplay first = new();
+ FakeDisplay second = new()
+ {
+ InitializeException = new IOException("second failed")
+ };
+ CompositeApplicationDisplay composite = new(first, second);
+
+ IOException exception =
+ Assert.Throws(() => composite.Initialize());
+
+ Assert.Equal("second failed", exception.Message);
+ Assert.Equal(1, first.StopCount);
+ Assert.Equal(0, second.StopCount);
+ }
+
+ [Fact]
+ public void CleanupFailure_DoesNotReplaceInitializationFailure()
+ {
+ FakeDisplay first = new()
+ {
+ StopException = new IOException("cleanup failed")
+ };
+ FakeDisplay second = new()
+ {
+ InitializeException = new InvalidOperationException(
+ "initialization failed")
+ };
+ CompositeApplicationDisplay composite = new(first, second);
+
+ InvalidOperationException exception =
+ Assert.Throws(
+ () => composite.Initialize());
+
+ Assert.Equal("initialization failed", exception.Message);
+ }
+
+ [Fact]
+ public void RenderAndStop_ReachEveryInitializedDisplay()
+ {
+ FakeDisplay first = new();
+ FakeDisplay second = new();
+ CompositeApplicationDisplay composite = new(first, second);
+ composite.Initialize();
+ TelemetrySnapshot snapshot = new();
+
+ composite.Render(snapshot, DisplayMode.Normal);
+ composite.Stop();
+ composite.Stop();
+
+ Assert.Equal(1, first.RenderCount);
+ Assert.Equal(1, second.RenderCount);
+ Assert.Equal(1, first.StopCount);
+ Assert.Equal(1, second.StopCount);
+ }
+
+ private sealed class FakeDisplay : IApplicationDisplay
+ {
+ public Exception? InitializeException { get; set; }
+
+ public Exception? StopException { get; set; }
+
+ public int RenderCount { get; private set; }
+
+ public int StopCount { get; private set; }
+
+ public void Initialize()
+ {
+ if (InitializeException is not null)
+ {
+ throw InitializeException;
+ }
+ }
+
+ public void Render(TelemetrySnapshot snapshot, DisplayMode mode) =>
+ RenderCount++;
+
+ public void Stop()
+ {
+ StopCount++;
+ if (StopException is not null)
+ {
+ throw StopException;
+ }
+ }
+ }
+}
diff --git a/LogiDynamicDash.Tests/ConsoleDashboardTests.cs b/LogiDynamicDash.Tests/ConsoleDashboardTests.cs
new file mode 100644
index 0000000..547acdb
--- /dev/null
+++ b/LogiDynamicDash.Tests/ConsoleDashboardTests.cs
@@ -0,0 +1,27 @@
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class ConsoleDashboardTests
+{
+ [Fact]
+ public void RedirectedMode_IsSafeAndSilent()
+ {
+ IApplicationDisplay dashboard =
+ new ConsoleDashboard(interactiveOverride: false);
+
+ dashboard.Initialize();
+ dashboard.Render(
+ new TelemetrySnapshot
+ {
+ ConnectionState = "CONNECTED",
+ Gear = 0,
+ SpeedMetersPerSecond = 0
+ },
+ DisplayMode.Normal);
+ dashboard.Flush();
+ dashboard.Stop();
+ dashboard.Stop();
+ }
+}
diff --git a/LogiDynamicDash.Tests/DisciplineProfileRecommendationTests.cs b/LogiDynamicDash.Tests/DisciplineProfileRecommendationTests.cs
new file mode 100644
index 0000000..6d36ad5
--- /dev/null
+++ b/LogiDynamicDash.Tests/DisciplineProfileRecommendationTests.cs
@@ -0,0 +1,81 @@
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class DisciplineProfileRecommendationTests
+{
+ [Fact]
+ public void SportsCar_UsesGearFocusedLayoutEAndClearTemporaryPages()
+ {
+ DisciplineProfileRecommendation recommendation =
+ DisciplineProfileRecommendations.Create(
+ IRacingDiscipline.SportsCar,
+ SpeedUnit.KilometersPerHour);
+
+ Assert.Equal(
+ Rs50OledLayout.E,
+ recommendation.Configuration.LayoutFor(DisplayMode.Normal));
+ Assert.Equal(
+ Rs50OledLayout.H,
+ recommendation.Configuration.LayoutFor(DisplayMode.BrakeBias));
+ Assert.Equal(
+ Rs50OledLayout.J,
+ recommendation.Configuration.LayoutFor(DisplayMode.LastLap));
+ Assert.Equal(300, recommendation.Configuration.GaugeMaximumSpeed);
+ }
+
+ [Fact]
+ public void Oval_UsesCompactLayoutDAndHigherSpeedScale()
+ {
+ DisciplineProfileRecommendation recommendation =
+ DisciplineProfileRecommendations.Create(
+ IRacingDiscipline.Oval,
+ SpeedUnit.MilesPerHour);
+
+ Assert.Equal(
+ Rs50OledLayout.D,
+ recommendation.Configuration.LayoutFor(DisplayMode.Normal));
+ Assert.Equal(9000, recommendation.Configuration.MaximumRpm);
+ Assert.Equal(225, recommendation.Configuration.GaugeMaximumSpeed);
+ }
+
+ [Theory]
+ [InlineData("SportsCar", "E", 8000, 300)]
+ [InlineData("FormulaCar", "E", 12000, 350)]
+ [InlineData("Oval", "D", 9000, 360)]
+ [InlineData("DirtOval", "D", 8500, 180)]
+ [InlineData("DirtRoad", "E", 9000, 220)]
+ public void CurrentCategory_HasAnExplicitProfile(
+ string disciplineName,
+ string normalLayoutName,
+ double maximumRpm,
+ double maximumSpeed)
+ {
+ IRacingDiscipline discipline =
+ Enum.Parse(disciplineName);
+ Rs50OledLayout normalLayout =
+ Enum.Parse(normalLayoutName);
+ DisciplineProfileRecommendation recommendation =
+ DisciplineProfileRecommendations.Create(
+ discipline,
+ SpeedUnit.KilometersPerHour);
+
+ Assert.Equal(normalLayout, recommendation.Configuration.LayoutFor(
+ DisplayMode.Normal));
+ Assert.Equal(maximumRpm, recommendation.Configuration.MaximumRpm);
+ Assert.Equal(maximumSpeed, recommendation.Configuration.GaugeMaximumSpeed);
+ }
+
+ [Theory]
+ [InlineData("Unknown")]
+ [InlineData("LegacyRoad")]
+ public void NonCurrentCategory_RequiresManualFallback(string disciplineName)
+ {
+ IRacingDiscipline discipline =
+ Enum.Parse(disciplineName);
+ Assert.Throws(
+ () => DisciplineProfileRecommendations.Create(
+ discipline,
+ SpeedUnit.KilometersPerHour));
+ }
+}
diff --git a/LogiDynamicDash.Tests/DisplayControllerTests.cs b/LogiDynamicDash.Tests/DisplayControllerTests.cs
new file mode 100644
index 0000000..9cbad99
--- /dev/null
+++ b/LogiDynamicDash.Tests/DisplayControllerTests.cs
@@ -0,0 +1,54 @@
+using LogiDynamicDash.Controllers;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class DisplayControllerTests
+{
+ [Fact]
+ public void SelectMode_DoesNotTreatFirstAvailableLapAsNewCompletion()
+ {
+ DisplayController controller = new();
+ TelemetrySnapshot snapshot = new()
+ {
+ ConnectionState = "CONNECTED"
+ };
+
+ Assert.Equal(DisplayMode.Normal, controller.SelectMode(snapshot));
+ snapshot.LastLapTimeSeconds = 91.2f;
+
+ Assert.Equal(DisplayMode.Normal, controller.SelectMode(snapshot));
+ }
+
+ [Fact]
+ public void LastLapDuration_IsConfigurable()
+ {
+ ManualTimeProvider clock = new();
+ DisplayController controller = new(
+ clock,
+ TimeSpan.FromSeconds(8));
+ TelemetrySnapshot snapshot = new()
+ {
+ ConnectionState = "CONNECTED",
+ LastLapTimeSeconds = 90
+ };
+ Assert.Equal(DisplayMode.Normal, controller.SelectMode(snapshot));
+ snapshot.LastLapTimeSeconds = 91;
+
+ Assert.Equal(DisplayMode.LastLap, controller.SelectMode(snapshot));
+ clock.Advance(TimeSpan.FromSeconds(7));
+ Assert.Equal(DisplayMode.LastLap, controller.SelectMode(snapshot));
+ clock.Advance(TimeSpan.FromSeconds(1));
+ Assert.Equal(DisplayMode.Normal, controller.SelectMode(snapshot));
+ }
+
+ private sealed class ManualTimeProvider : TimeProvider
+ {
+ private DateTimeOffset now = DateTimeOffset.UnixEpoch;
+
+ public override DateTimeOffset GetUtcNow() => now;
+
+ internal void Advance(TimeSpan duration) =>
+ now += duration;
+ }
+}
diff --git a/LogiDynamicDash.Tests/Golden/layouts.txt b/LogiDynamicDash.Tests/Golden/layouts.txt
new file mode 100644
index 0000000..dd4267e
--- /dev/null
+++ b/LogiDynamicDash.Tests/Golden/layouts.txt
@@ -0,0 +1,50 @@
+LAYOUT A
+ Normal: blank
+ BrakeBias: blank
+ LastLap: blank
+ ConnectionProblem: blank
+LAYOUT B
+ Normal: firmware-test
+ BrakeBias: firmware-test
+ LastLap: firmware-test
+ ConnectionProblem: firmware-test
+LAYOUT C
+ Normal: main=207
+ BrakeBias: main=207
+ LastLap: main=207
+ ConnectionProblem: main=207
+LAYOUT D
+ Normal: main=207 thin=105 text="3 123K"
+ BrakeBias: main=207 thin=105 text="BB 52.3%"
+ LastLap: main=207 thin=105 text="L 1:32.481"
+ ConnectionProblem: main=207 thin=105 text="OFFLINE"
+LAYOUT E
+ Normal: main=207 thin=105 left="123 KMH" right="3"
+ BrakeBias: main=207 thin=105 left="52.3%" right="BB"
+ LastLap: main=207 thin=105 left="1:32.4" right="LAP"
+ ConnectionProblem: main=207 thin=105 left="OFFLINE" right="ERR"
+LAYOUT F
+ Normal: left="3" right="123"
+ BrakeBias: left="B" right="52"
+ LastLap: left="L" right="92"
+ ConnectionProblem: left="!" right="ERR"
+LAYOUT G
+ Normal: left="3" right="123"
+ BrakeBias: left="B" right="52"
+ LastLap: left="L" right="92"
+ ConnectionProblem: left="!" right="ERR"
+LAYOUT H
+ Normal: top="SPEED 123 KMH" bottom="GEAR 3"
+ BrakeBias: top="BRAKE BIAS" bottom="52.3%"
+ LastLap: top="LAST LAP" bottom="1:32.481"
+ ConnectionProblem: top="IRACING" bottom="ERROR"
+LAYOUT I
+ Normal: line1="SPEED" line2="123 KMH" line3="GEAR" line4="3"
+ BrakeBias: line1="BRAKE BIAS" line2="52.3%" line3="" line4=""
+ LastLap: line1="LAST LAP" line2="1:32.481" line3="" line4=""
+ ConnectionProblem: line1="IRACING" line2="ERROR" line3="" line4=""
+LAYOUT J
+ Normal: line1="SPEED" line2="123 KMH" line3="GEAR" line4="3"
+ BrakeBias: line1="BRAKE BIAS" line2="52.3%" line3="" line4=""
+ LastLap: line1="LAST LAP" line2="1:32.481" line3="" line4=""
+ ConnectionProblem: line1="IRACING" line2="ERROR" line3="" line4=""
diff --git a/LogiDynamicDash.Tests/GoldenPreviewTests.cs b/LogiDynamicDash.Tests/GoldenPreviewTests.cs
new file mode 100644
index 0000000..b31daa2
--- /dev/null
+++ b/LogiDynamicDash.Tests/GoldenPreviewTests.cs
@@ -0,0 +1,26 @@
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Offline;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class GoldenPreviewTests
+{
+ [Fact]
+ public void PreviewAll_MatchesReviewedGoldenOutput()
+ {
+ StringWriter output = new();
+ Rs50OledPreviewRunner.RunAll(
+ new Rs50OledConfiguration(Rs50OledLayout.E),
+ output);
+ string expected = File.ReadAllText(
+ Path.Combine(
+ AppContext.BaseDirectory,
+ "Golden",
+ "layouts.txt"));
+
+ Assert.Equal(Normalize(expected), Normalize(output.ToString()));
+ }
+
+ private static string Normalize(string value) =>
+ value.ReplaceLineEndings("\n").TrimEnd();
+}
diff --git a/LogiDynamicDash.Tests/IRacingSessionIdentityTests.cs b/LogiDynamicDash.Tests/IRacingSessionIdentityTests.cs
new file mode 100644
index 0000000..3d375fb
--- /dev/null
+++ b/LogiDynamicDash.Tests/IRacingSessionIdentityTests.cs
@@ -0,0 +1,100 @@
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Services;
+using SVappsLAB.iRacingTelemetrySDK;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class IRacingSessionIdentityTests
+{
+ [Theory]
+ [InlineData("SportsCar", "SportsCar")]
+ [InlineData("sports car", "SportsCar")]
+ [InlineData("FormulaCar", "FormulaCar")]
+ [InlineData("formula_car", "FormulaCar")]
+ [InlineData("Oval", "Oval")]
+ [InlineData("DirtOval", "DirtOval")]
+ [InlineData("dirt-road", "DirtRoad")]
+ [InlineData("Road", "LegacyRoad")]
+ [InlineData("FutureCategory", "Unknown")]
+ public void CategoryParser_IsTolerantButDoesNotGuess(
+ string rawCategory,
+ string expectedName)
+ {
+ Assert.Equal(
+ expectedName,
+ IRacingDisciplineParser.Parse(rawCategory).ToString());
+ }
+
+ [Fact]
+ public void Resolver_UsesOfficialCategoryAndCapturesDriverCarIdentity()
+ {
+ TelemetrySessionInfo session = new()
+ {
+ WeekendInfo = new WeekendInfo
+ {
+ Category = "SportsCar",
+ TrackType = "oval"
+ },
+ DriverInfo = new DriverInfo
+ {
+ DriverCarIdx = 7,
+ Drivers =
+ [
+ new Driver
+ {
+ CarIdx = 3,
+ CarID = 111,
+ CarScreenName = "Other car"
+ },
+ new Driver
+ {
+ CarIdx = 7,
+ CarID = 222,
+ CarPath = "stockcars example",
+ CarScreenName = "Example GT",
+ CarScreenNameShort = "GT",
+ CarClassID = 333,
+ CarClassShortName = "GT3",
+ CarIsElectric = 1
+ }
+ ]
+ }
+ };
+
+ IRacingSessionIdentity identity =
+ IRacingSessionIdentityResolver.Resolve(session);
+
+ Assert.Equal(IRacingDiscipline.SportsCar, identity.Discipline);
+ Assert.Equal("oval", identity.TrackType);
+ Assert.NotNull(identity.Car);
+ Assert.Equal(222, identity.Car.CarId);
+ Assert.Equal("stockcars example", identity.Car.CarPath);
+ Assert.Equal("Example GT", identity.Car.DisplayName);
+ Assert.Equal("GT3", identity.Car.CarClassShortName);
+ Assert.True(identity.Car.IsElectric);
+ }
+
+ [Fact]
+ public void Resolver_MissingDriverFailsClosedWithoutInventingACar()
+ {
+ TelemetrySessionInfo session = new()
+ {
+ WeekendInfo = new WeekendInfo
+ {
+ Category = "DirtRoad",
+ TrackType = "dirt road"
+ },
+ DriverInfo = new DriverInfo
+ {
+ DriverCarIdx = 9,
+ Drivers = []
+ }
+ };
+
+ IRacingSessionIdentity identity =
+ IRacingSessionIdentityResolver.Resolve(session);
+
+ Assert.Equal(IRacingDiscipline.DirtRoad, identity.Discipline);
+ Assert.Null(identity.Car);
+ }
+}
diff --git a/LogiDynamicDash.Tests/LogiDynamicDash.Tests.csproj b/LogiDynamicDash.Tests/LogiDynamicDash.Tests.csproj
new file mode 100644
index 0000000..ac2775c
--- /dev/null
+++ b/LogiDynamicDash.Tests/LogiDynamicDash.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LogiDynamicDash.Tests/LogiDynamicDashApplicationTests.cs b/LogiDynamicDash.Tests/LogiDynamicDashApplicationTests.cs
new file mode 100644
index 0000000..38ade19
--- /dev/null
+++ b/LogiDynamicDash.Tests/LogiDynamicDashApplicationTests.cs
@@ -0,0 +1,286 @@
+using LogiDynamicDash.Controllers;
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Services;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class LogiDynamicDashApplicationTests
+{
+ [Fact]
+ public async Task RunAsync_UsesInjectedSourceAndStopsCleanly()
+ {
+ ManualTimeProvider clock = new();
+ RecordingDisplay display = new();
+ ScriptedSource source = new(clock);
+ LogiDynamicDashApplication application = new(
+ source,
+ display,
+ new DisplayController(clock),
+ clock);
+
+ await application.RunAsync(CancellationToken.None);
+
+ Assert.Equal(ApplicationLifecycleState.Stopped, application.State);
+ Assert.Equal(1, display.InitializeCount);
+ Assert.Equal(1, display.StopCount);
+ Assert.Equal(3, display.Modes.Count);
+ Assert.Equal(DisplayMode.ConnectionProblem, display.Modes[0]);
+ Assert.Equal(DisplayMode.Normal, display.Modes[1]);
+ }
+
+ [Fact]
+ public async Task RunAsync_FaultsPermanentlyAndDoesNotRestart()
+ {
+ RecordingDisplay display = new()
+ {
+ RenderException = new IOException("injected")
+ };
+ LogiDynamicDashApplication application = new(
+ new EmptySource(),
+ display,
+ new DisplayController());
+
+ await Assert.ThrowsAsync(
+ () => application.RunAsync(CancellationToken.None));
+
+ Assert.Equal(ApplicationLifecycleState.Faulted, application.State);
+ await Assert.ThrowsAsync(
+ () => application.RunAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task RunAsync_SerializesConcurrentStatusCallbacks()
+ {
+ ConcurrentRecordingDisplay display = new();
+ LogiDynamicDashApplication application = new(
+ new ConcurrentSource(),
+ display,
+ new DisplayController());
+
+ await application.RunAsync(CancellationToken.None);
+
+ Assert.Equal(101, display.RenderCount);
+ Assert.Equal(1, display.MaximumConcurrentRenders);
+ }
+
+ [Fact]
+ public async Task RunAsync_TreatsRequestedCancellationAsCleanStop()
+ {
+ RecordingDisplay display = new();
+ LogiDynamicDashApplication application = new(
+ new CancellationSource(),
+ display,
+ new DisplayController());
+ using CancellationTokenSource cancellation = new();
+ cancellation.Cancel();
+
+ await application.RunAsync(cancellation.Token);
+
+ Assert.Equal(ApplicationLifecycleState.Stopped, application.State);
+ Assert.Equal(1, display.StopCount);
+ }
+
+ [Fact]
+ public async Task RunAsync_FlushesPendingDisplaysWithoutTelemetryCallbacks()
+ {
+ RecordingDisplay display = new();
+ LogiDynamicDashApplication application = new(
+ new DelayedSource(),
+ display,
+ new DisplayController());
+
+ await application.RunAsync(CancellationToken.None);
+
+ Assert.True(display.FlushCount >= 2);
+ Assert.Equal(ApplicationLifecycleState.Stopped, application.State);
+ }
+
+ [Fact]
+ public async Task RunAsync_FlushFailureCancelsSourceAndFaultsApplication()
+ {
+ RecordingDisplay display = new()
+ {
+ FlushException = new IOException("injected flush failure")
+ };
+ LogiDynamicDashApplication application = new(
+ new DelayedSource(),
+ display,
+ new DisplayController());
+
+ await Assert.ThrowsAsync(
+ () => application.RunAsync(CancellationToken.None));
+
+ Assert.Equal(ApplicationLifecycleState.Faulted, application.State);
+ Assert.Equal(1, display.StopCount);
+ }
+
+ private sealed class ScriptedSource(ManualTimeProvider clock)
+ : ITelemetrySource
+ {
+ public Task MonitorAsync(
+ Action onTelemetryUpdated,
+ Action onStatusChanged,
+ CancellationToken cancellationToken)
+ {
+ TelemetrySnapshot snapshot = new()
+ {
+ ConnectionState = "CONNECTED",
+ SpeedMetersPerSecond = 0
+ };
+ onStatusChanged(snapshot);
+ clock.Advance(TimeSpan.FromMilliseconds(100));
+ onTelemetryUpdated(new TelemetrySnapshot
+ {
+ ConnectionState = "CONNECTED",
+ SpeedMetersPerSecond = 0,
+ Gear = 1
+ });
+ clock.Advance(TimeSpan.FromMilliseconds(100));
+ onTelemetryUpdated(new TelemetrySnapshot
+ {
+ ConnectionState = "CONNECTED",
+ SpeedMetersPerSecond = 0,
+ Gear = 2
+ });
+ return Task.CompletedTask;
+ }
+ }
+
+ private sealed class EmptySource : ITelemetrySource
+ {
+ public Task MonitorAsync(
+ Action onTelemetryUpdated,
+ Action onStatusChanged,
+ CancellationToken cancellationToken) =>
+ Task.CompletedTask;
+ }
+
+ private sealed class ConcurrentSource : ITelemetrySource
+ {
+ public Task MonitorAsync(
+ Action onTelemetryUpdated,
+ Action onStatusChanged,
+ CancellationToken cancellationToken)
+ {
+ Parallel.For(
+ 0,
+ 100,
+ index => onStatusChanged(new TelemetrySnapshot
+ {
+ ConnectionState = "CONNECTED",
+ Gear = index
+ }));
+ return Task.CompletedTask;
+ }
+ }
+
+ private sealed class CancellationSource : ITelemetrySource
+ {
+ public Task MonitorAsync(
+ Action onTelemetryUpdated,
+ Action onStatusChanged,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.CompletedTask;
+ }
+ }
+
+ private sealed class DelayedSource : ITelemetrySource
+ {
+ public async Task MonitorAsync(
+ Action onTelemetryUpdated,
+ Action onStatusChanged,
+ CancellationToken cancellationToken) =>
+ await Task.Delay(
+ TimeSpan.FromMilliseconds(450),
+ cancellationToken);
+ }
+
+ private sealed class RecordingDisplay : IApplicationDisplay
+ {
+ public List Modes { get; } = [];
+ public int InitializeCount { get; private set; }
+ public int StopCount { get; private set; }
+ public int FlushCount { get; private set; }
+ public Exception? RenderException { get; set; }
+ public Exception? FlushException { get; set; }
+
+ public void Initialize() => InitializeCount++;
+
+ public void Render(TelemetrySnapshot snapshot, DisplayMode mode)
+ {
+ if (RenderException is not null)
+ {
+ throw RenderException;
+ }
+
+ Modes.Add(mode);
+ }
+
+ public void Stop() => StopCount++;
+
+ public void Flush()
+ {
+ FlushCount++;
+ if (FlushException is not null)
+ {
+ throw FlushException;
+ }
+ }
+ }
+
+ private sealed class ConcurrentRecordingDisplay : IApplicationDisplay
+ {
+ private int concurrentRenders;
+ private int maximumConcurrentRenders;
+ private int renderCount;
+
+ public int RenderCount => renderCount;
+ public int MaximumConcurrentRenders => maximumConcurrentRenders;
+
+ public void Initialize()
+ {
+ }
+
+ public void Render(TelemetrySnapshot snapshot, DisplayMode mode)
+ {
+ int concurrent = Interlocked.Increment(ref concurrentRenders);
+ int observed;
+ do
+ {
+ observed = maximumConcurrentRenders;
+ if (observed >= concurrent)
+ {
+ break;
+ }
+ }
+ while (Interlocked.CompareExchange(
+ ref maximumConcurrentRenders,
+ concurrent,
+ observed) != observed);
+
+ Thread.SpinWait(10_000);
+ Interlocked.Increment(ref renderCount);
+ Interlocked.Decrement(ref concurrentRenders);
+ }
+
+ public void Stop()
+ {
+ }
+ }
+
+ private sealed class ManualTimeProvider : TimeProvider
+ {
+ private long timestamp;
+ private readonly DateTimeOffset epoch =
+ new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+ public override long GetTimestamp() => timestamp;
+ public override DateTimeOffset GetUtcNow() => epoch.AddTicks(timestamp);
+
+ public void Advance(TimeSpan duration) => timestamp += duration.Ticks;
+ }
+}
diff --git a/LogiDynamicDash.Tests/OfflineCommandLineTests.cs b/LogiDynamicDash.Tests/OfflineCommandLineTests.cs
new file mode 100644
index 0000000..1b34f48
--- /dev/null
+++ b/LogiDynamicDash.Tests/OfflineCommandLineTests.cs
@@ -0,0 +1,77 @@
+using LogiDynamicDash.Offline;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class OfflineCommandLineTests
+{
+ [Theory]
+ [InlineData("--preview-all", 0)]
+ [InlineData("--simulate-all", 1)]
+ public void TryParse_AcceptsExactOfflineCommands(
+ string verb,
+ int expectedKind)
+ {
+ Assert.True(
+ OfflineCommandLine.TryParse(
+ [verb, "--config", "settings.json"],
+ out OfflineCommand? command));
+
+ Assert.NotNull(command);
+ Assert.Equal((OfflineCommandKind)expectedKind, command.Kind);
+ Assert.Equal("settings.json", command.ConfigurationPath);
+ }
+
+ [Fact]
+ public void TryParse_AcceptsExactReplayCommand()
+ {
+ Assert.True(
+ OfflineCommandLine.TryParse(
+ [
+ "--replay",
+ "--config",
+ "settings.json",
+ "--telemetry",
+ "scenario.json"
+ ],
+ out OfflineCommand? command));
+
+ Assert.NotNull(command);
+ Assert.Equal(OfflineCommandKind.Replay, command.Kind);
+ Assert.Equal("settings.json", command.ConfigurationPath);
+ Assert.Equal("scenario.json", command.TelemetryPath);
+ }
+
+ [Fact]
+ public void TryParse_AcceptsBoundedTelemetryRecording()
+ {
+ Assert.True(
+ OfflineCommandLine.TryParse(
+ [
+ "--record-telemetry",
+ "--output",
+ "recording.json",
+ "--duration-seconds",
+ "300"
+ ],
+ out OfflineCommand? command));
+
+ Assert.NotNull(command);
+ Assert.Equal(OfflineCommandKind.RecordTelemetry, command.Kind);
+ Assert.Equal("recording.json", command.OutputPath);
+ Assert.Equal(300, command.DurationSeconds);
+ }
+
+ [Theory]
+ [InlineData("--preview")]
+ [InlineData("--simulate")]
+ [InlineData("--preview-all")]
+ public void TryParse_RejectsIncompleteOrUnknownCommands(string verb)
+ {
+ Assert.False(
+ OfflineCommandLine.TryParse([verb], out _));
+ Assert.False(
+ OfflineCommandLine.TryParse(
+ [verb, "--wrong", "settings.json"],
+ out _));
+ }
+}
diff --git a/LogiDynamicDash.Tests/RecoveringRs50OledDisplaySinkTests.cs b/LogiDynamicDash.Tests/RecoveringRs50OledDisplaySinkTests.cs
new file mode 100644
index 0000000..600cf9d
--- /dev/null
+++ b/LogiDynamicDash.Tests/RecoveringRs50OledDisplaySinkTests.cs
@@ -0,0 +1,110 @@
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Runtime;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class RecoveringRs50OledDisplaySinkTests
+{
+ [Fact]
+ public void MissingDeviceAtStartup_ReconnectsAndSendsLatestFrame()
+ {
+ ManualTimeProvider clock = new();
+ Queue sessions = new(
+ [
+ new FakeSession { OpenException = new IOException("missing") },
+ new FakeSession()
+ ]);
+ List states = [];
+ RecoveringRs50OledDisplaySink sink = new(
+ () => sessions.Dequeue(),
+ new Rs50TelemetryFrameFormatter(
+ new Rs50OledConfiguration(Rs50OledLayout.E)),
+ states.Add,
+ clock);
+
+ sink.Initialize();
+ sink.Render(ConnectedSnapshot(), DisplayMode.Normal);
+ clock.Advance(TimeSpan.FromSeconds(2));
+ sink.Flush();
+
+ Assert.Contains(DashboardOledState.Waiting, states);
+ Assert.Contains(DashboardOledState.Connected, states);
+ Assert.Empty(sessions);
+ sink.Stop();
+ Assert.Equal(DashboardOledState.Stopped, states[^1]);
+ }
+
+ [Fact]
+ public void SendFailure_IsContainedAndSchedulesReconnect()
+ {
+ FakeSession failing = new()
+ {
+ SendException = new IOException("disconnected")
+ };
+ List states = [];
+ RecoveringRs50OledDisplaySink sink = new(
+ () => failing,
+ new Rs50TelemetryFrameFormatter(
+ new Rs50OledConfiguration(Rs50OledLayout.E)),
+ states.Add);
+ sink.Initialize();
+
+ sink.Render(ConnectedSnapshot(), DisplayMode.Normal);
+
+ Assert.True(failing.Disposed);
+ Assert.Equal(DashboardOledState.Reconnecting, states[^1]);
+ sink.Stop();
+ }
+
+ private static TelemetrySnapshot ConnectedSnapshot() =>
+ new()
+ {
+ ConnectionState = "CONNECTED",
+ IsOnTrack = true,
+ SpeedMetersPerSecond = 0,
+ Gear = 0,
+ Rpm = 1000
+ };
+
+ private sealed class FakeSession : IRs50OledSession
+ {
+ internal Exception? OpenException { get; init; }
+ internal Exception? SendException { get; init; }
+ internal bool Disposed { get; private set; }
+
+ public void Open()
+ {
+ if (OpenException is not null)
+ {
+ throw OpenException;
+ }
+ }
+
+ public Rs50OledSendResult Send(Rs50OledFrame frame)
+ {
+ if (SendException is not null)
+ {
+ throw SendException;
+ }
+
+ return Rs50OledSendResult.Transmitted;
+ }
+
+ public void Dispose() =>
+ Disposed = true;
+ }
+
+ private sealed class ManualTimeProvider : TimeProvider
+ {
+ private long timestamp;
+
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+
+ public override long GetTimestamp() => timestamp;
+
+ internal void Advance(TimeSpan duration) =>
+ timestamp += duration.Ticks;
+ }
+}
diff --git a/LogiDynamicDash.Tests/Rs50DrivingTrialOptionsTests.cs b/LogiDynamicDash.Tests/Rs50DrivingTrialOptionsTests.cs
new file mode 100644
index 0000000..c78a5a7
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50DrivingTrialOptionsTests.cs
@@ -0,0 +1,56 @@
+using LogiDynamicDash.Configuration;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50DrivingTrialOptionsTests
+{
+ [Fact]
+ public void TryParse_AcceptsOnlyCompleteOrderedArmingContract()
+ {
+ Assert.True(
+ Rs50DrivingTrialOptions.TryParse(
+ ValidArguments(),
+ out Rs50DrivingTrialOptions? options));
+
+ Assert.NotNull(options);
+ Assert.Equal("settings.json", options.ConfigurationPath);
+ }
+
+ [Fact]
+ public void TryParse_RejectsMissingReorderedOrExtraArgument()
+ {
+ string[] missing = ValidArguments()[..^1];
+ Assert.False(Rs50DrivingTrialOptions.TryParse(missing, out _));
+
+ string[] reordered = ValidArguments();
+ (reordered[5], reordered[6]) =
+ (reordered[6], reordered[5]);
+ Assert.False(Rs50DrivingTrialOptions.TryParse(reordered, out _));
+
+ string[] extra = [.. ValidArguments(), "--extra"];
+ Assert.False(Rs50DrivingTrialOptions.TryParse(extra, out _));
+ }
+
+ [Fact]
+ public void TryParse_RejectsEmptyConfigurationPath()
+ {
+ string[] arguments = ValidArguments();
+ arguments[8] = " ";
+
+ Assert.False(Rs50DrivingTrialOptions.TryParse(arguments, out _));
+ }
+
+ internal static string[] ValidArguments() =>
+ [
+ "--enable-rs50-oled-driving-trial",
+ "--confirm-ghub-closed",
+ "--confirm-iracing-running",
+ "--confirm-controlled-driving-session",
+ "--confirm-rs50-dynamic-selected",
+ "--acknowledge-no-speed-limit",
+ "--acknowledge-manual-stop-required",
+ "--config",
+ "settings.json",
+ "--confirm-settings"
+ ];
+}
diff --git a/LogiDynamicDash.Tests/Rs50LowSpeedTrialOptionsTests.cs b/LogiDynamicDash.Tests/Rs50LowSpeedTrialOptionsTests.cs
new file mode 100644
index 0000000..2bb2556
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50LowSpeedTrialOptionsTests.cs
@@ -0,0 +1,63 @@
+using LogiDynamicDash.Configuration;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50LowSpeedTrialOptionsTests
+{
+ [Fact]
+ public void TryParse_AcceptsOnlyCompleteOrderedArmingContract()
+ {
+ Assert.True(
+ Rs50LowSpeedTrialOptions.TryParse(
+ ValidArguments(),
+ out Rs50LowSpeedTrialOptions? options));
+
+ Assert.NotNull(options);
+ Assert.Equal("settings.json", options.ConfigurationPath);
+ Assert.Equal(
+ 20f / 3.6f,
+ Rs50LowSpeedTrialOptions.MaximumSpeedMetersPerSecond);
+ Assert.Equal(
+ TimeSpan.FromSeconds(15),
+ Rs50LowSpeedTrialOptions.Duration);
+ }
+
+ [Fact]
+ public void TryParse_RejectsMissingReorderedOrExtraArgument()
+ {
+ string[] missing = ValidArguments()[..^1];
+ Assert.False(Rs50LowSpeedTrialOptions.TryParse(missing, out _));
+
+ string[] reordered = ValidArguments();
+ (reordered[6], reordered[7]) =
+ (reordered[7], reordered[6]);
+ Assert.False(Rs50LowSpeedTrialOptions.TryParse(reordered, out _));
+
+ string[] extra = [.. ValidArguments(), "--extra"];
+ Assert.False(Rs50LowSpeedTrialOptions.TryParse(extra, out _));
+ }
+
+ [Fact]
+ public void TryParse_RejectsEmptyConfigurationPath()
+ {
+ string[] arguments = ValidArguments();
+ arguments[9] = " ";
+
+ Assert.False(Rs50LowSpeedTrialOptions.TryParse(arguments, out _));
+ }
+
+ internal static string[] ValidArguments() =>
+ [
+ "--enable-rs50-oled-low-speed-trial",
+ "--confirm-ghub-closed",
+ "--confirm-iracing-running",
+ "--confirm-controlled-pit-lane",
+ "--confirm-rs50-dynamic-selected",
+ "--confirm-15-second-limit",
+ "--confirm-maximum-20-kmh",
+ "--acknowledge-stop-on-speed-limit",
+ "--config",
+ "settings.json",
+ "--confirm-settings"
+ ];
+}
diff --git a/LogiDynamicDash.Tests/Rs50OledConfigurationFileTests.cs b/LogiDynamicDash.Tests/Rs50OledConfigurationFileTests.cs
new file mode 100644
index 0000000..fc822bd
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50OledConfigurationFileTests.cs
@@ -0,0 +1,115 @@
+using LogiDynamicDash.Configuration;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50OledConfigurationFileTests
+{
+ private const string ValidJson =
+ """
+ {
+ "schemaVersion": 1,
+ "layout": "E",
+ "speedUnit": "KMH",
+ "maximumRpm": 8000,
+ "gaugeMaximumSpeed": 300
+ }
+ """;
+
+ [Fact]
+ public void Parse_AcceptsStrictVersionedConfiguration()
+ {
+ Rs50OledConfiguration configuration =
+ Rs50OledConfigurationFile.Parse(ValidJson);
+
+ Assert.Equal(Rs50OledLayout.E, configuration.Layout);
+ Assert.Equal(
+ SpeedUnit.KilometersPerHour,
+ configuration.SpeedUnit);
+ Assert.Equal(8000, configuration.MaximumRpm);
+ Assert.Equal(300, configuration.GaugeMaximumSpeed);
+ }
+
+ [Fact]
+ public void Parse_AcceptsVersionTwoPerModeLayouts()
+ {
+ const string json =
+ """
+ {
+ "schemaVersion": 2,
+ "layouts": {
+ "normal": "E",
+ "brakeBias": "H",
+ "lastLap": "J",
+ "connectionProblem": "A"
+ },
+ "speedUnit": "MPH",
+ "maximumRpm": 9000,
+ "gaugeMaximumSpeed": 200
+ }
+ """;
+
+ Rs50OledConfiguration configuration =
+ Rs50OledConfigurationFile.Parse(json);
+
+ Assert.Equal(Rs50OledLayout.E, configuration.Layout);
+ Assert.Equal(
+ Rs50OledLayout.H,
+ configuration.LayoutFor(DisplayMode.BrakeBias));
+ Assert.Equal(
+ Rs50OledLayout.J,
+ configuration.LayoutFor(DisplayMode.LastLap));
+ Assert.Equal(
+ Rs50OledLayout.A,
+ configuration.LayoutFor(DisplayMode.ConnectionProblem));
+ }
+
+ [Fact]
+ public void Serialize_WritesStrictRoundTrippableVersionTwoConfiguration()
+ {
+ Rs50OledConfiguration original =
+ DisciplineProfileRecommendations.Create(
+ IRacingDiscipline.Oval,
+ SpeedUnit.MilesPerHour).Configuration;
+
+ string json = Rs50OledConfigurationFile.Serialize(original);
+ Rs50OledConfiguration parsed =
+ Rs50OledConfigurationFile.Parse(json);
+
+ Assert.Contains("\"schemaVersion\": 2", json);
+ Assert.Equal(
+ original.LayoutFor(DisplayMode.Normal),
+ parsed.LayoutFor(DisplayMode.Normal));
+ Assert.Equal(original.SpeedUnit, parsed.SpeedUnit);
+ Assert.Equal(original.MaximumRpm, parsed.MaximumRpm);
+ Assert.Equal(
+ original.GaugeMaximumSpeed,
+ parsed.GaugeMaximumSpeed);
+ }
+
+ [Theory]
+ [InlineData("""{"schemaVersion":2,"layout":"E","speedUnit":"KMH","maximumRpm":8000,"gaugeMaximumSpeed":300}""")]
+ [InlineData("""{"schemaVersion":2,"layouts":{"normal":"E","brakeBias":"H","lastLap":"J"},"speedUnit":"KMH","maximumRpm":8000,"gaugeMaximumSpeed":300}""")]
+ [InlineData("""{"schemaVersion":1,"layout":"e","speedUnit":"KMH","maximumRpm":8000,"gaugeMaximumSpeed":300}""")]
+ [InlineData("""{"schemaVersion":1,"layout":"E","speedUnit":"kmh","maximumRpm":8000,"gaugeMaximumSpeed":300}""")]
+ [InlineData("""{"schemaVersion":1,"layout":"E","speedUnit":"KMH","maximumRpm":999,"gaugeMaximumSpeed":300}""")]
+ [InlineData("""{"schemaVersion":1,"layout":"E","speedUnit":"KMH","maximumRpm":8000,"gaugeMaximumSpeed":501}""")]
+ [InlineData("""{"schemaVersion":1,"layout":"E","speedUnit":"KMH","maximumRpm":8000,"gaugeMaximumSpeed":300,"extra":true}""")]
+ [InlineData("""{"schemaVersion":1,"schemaVersion":1,"layout":"E","speedUnit":"KMH","maximumRpm":8000,"gaugeMaximumSpeed":300}""")]
+ public void Parse_RejectsInvalidOrAmbiguousConfiguration(string json)
+ {
+ Assert.ThrowsAny(
+ () => Rs50OledConfigurationFile.Parse(json));
+ }
+
+ [Fact]
+ public void Parse_RejectsMissingPropertyAndOversizedInput()
+ {
+ Assert.Throws(
+ () => Rs50OledConfigurationFile.Parse(
+ """{"schemaVersion":1}"""));
+ Assert.Throws(
+ () => Rs50OledConfigurationFile.Parse(
+ new string(' ', 16 * 1024 + 1)));
+ }
+}
diff --git a/LogiDynamicDash.Tests/Rs50OledDeviceExchangeTests.cs b/LogiDynamicDash.Tests/Rs50OledDeviceExchangeTests.cs
new file mode 100644
index 0000000..be42375
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50OledDeviceExchangeTests.cs
@@ -0,0 +1,281 @@
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Hidpp.Transport;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50OledDeviceExchangeTests
+{
+ [Fact]
+ public void Open_RequiresExactUniqueCollections()
+ {
+ FakeCollection shortCollection = ShortCollection();
+ FakeCollection longCollection = LongCollection();
+
+ using Rs50OledDeviceExchange exchange =
+ Rs50OledDeviceExchange.Open(
+ new FakeCatalog(shortCollection, longCollection));
+
+ Assert.Equal(1, shortCollection.OpenCount);
+ Assert.Equal(1, longCollection.OpenCount);
+ }
+
+ [Fact]
+ public void Open_RejectsMissingDuplicateOrMalformedCollection()
+ {
+ Assert.Throws(
+ () => Rs50OledDeviceExchange.Open(
+ new FakeCatalog(LongCollection())));
+
+ Assert.Throws(
+ () => Rs50OledDeviceExchange.Open(
+ new FakeCatalog(
+ ShortCollection(),
+ ShortCollection(),
+ LongCollection())));
+
+ FakeCollection malformed = ShortCollection();
+ malformed.MaximumOutputReportLength = 64;
+ Assert.Throws(
+ () => Rs50OledDeviceExchange.Open(
+ new FakeCatalog(malformed, LongCollection())));
+ }
+
+ [Fact]
+ public void Open_DisposesShortStreamWhenLongOpenFails()
+ {
+ FakeCollection shortCollection = ShortCollection();
+ FakeCollection longCollection = LongCollection();
+ longCollection.OpenException = new IOException("blocked");
+
+ Assert.Throws(
+ () => Rs50OledDeviceExchange.Open(
+ new FakeCatalog(shortCollection, longCollection)));
+
+ Assert.True(shortCollection.Stream.Disposed);
+ }
+
+ [Fact]
+ public void Discovery_WritesShortAndReadsMatchingLongResponse()
+ {
+ FakeCollection shortCollection = ShortCollection();
+ FakeCollection longCollection = LongCollection();
+ longCollection.Stream.Enqueue(UnrelatedResponse());
+ byte[] expected = DiscoveryResponse();
+ longCollection.Stream.Enqueue(expected);
+
+ using Rs50OledDeviceExchange exchange =
+ Rs50OledDeviceExchange.Open(
+ new FakeCatalog(shortCollection, longCollection));
+ byte[] actual =
+ exchange.Exchange(Rs50OledProtocol.CreateDiscovery());
+
+ Assert.Equal(expected, actual);
+ Assert.Single(shortCollection.Stream.Writes);
+ Assert.Empty(longCollection.Stream.Writes);
+ Assert.Equal(
+ [0x10, 0xFF, 0x00, 0x0A, 0x81, 0x30, 0x00],
+ shortCollection.Stream.Writes[0]);
+ }
+
+ [Fact]
+ public void Layout_WritesLongAndAcceptsOnlyMatchingResponse()
+ {
+ FakeCollection shortCollection = ShortCollection();
+ FakeCollection longCollection = LongCollection();
+ longCollection.Stream.Enqueue(UnrelatedResponse());
+ byte[] expected = LayoutResponse();
+ longCollection.Stream.Enqueue(expected);
+
+ using Rs50OledDeviceExchange exchange =
+ Rs50OledDeviceExchange.Open(
+ new FakeCatalog(shortCollection, longCollection));
+ Rs50OledTransaction transaction = Rs50OledProtocol.CreateLayout(
+ 0x12,
+ new Rs50LayoutJFrame("SPEED", "0 KMH", "GEAR", "N"));
+ byte[] actual = exchange.Exchange(transaction);
+
+ Assert.Equal(expected, actual);
+ Assert.Empty(shortCollection.Stream.Writes);
+ Assert.Single(longCollection.Stream.Writes);
+ Assert.Equal(transaction.Request, longCollection.Stream.Writes[0]);
+ }
+
+ [Fact]
+ public void Exchange_AcceptsResponseAfterMoreThanSixteenReports()
+ {
+ FakeCollection shortCollection = ShortCollection();
+ FakeCollection longCollection = LongCollection();
+ for (int index = 0; index < 32; index++)
+ {
+ longCollection.Stream.Enqueue(UnrelatedResponse());
+ }
+ longCollection.Stream.Enqueue(DiscoveryResponse());
+
+ using Rs50OledDeviceExchange exchange =
+ Rs50OledDeviceExchange.Open(
+ new FakeCatalog(shortCollection, longCollection));
+
+ exchange.Exchange(Rs50OledProtocol.CreateDiscovery());
+
+ Assert.Single(shortCollection.Stream.Writes);
+ Assert.Equal(33, longCollection.Stream.ReadCount);
+ }
+
+ [Fact]
+ public void Exchange_FailsAfterBoundedReportsWithoutRetryingWrite()
+ {
+ FakeCollection shortCollection = ShortCollection();
+ FakeCollection longCollection = LongCollection();
+ for (int index = 0; index < 256; index++)
+ {
+ longCollection.Stream.Enqueue(UnrelatedResponse());
+ }
+
+ using Rs50OledDeviceExchange exchange =
+ Rs50OledDeviceExchange.Open(
+ new FakeCatalog(shortCollection, longCollection));
+
+ Rs50OledAcknowledgementTimeoutException exception =
+ Assert.Throws(
+ () => exchange.Exchange(
+ Rs50OledProtocol.CreateDiscovery()));
+ Assert.Equal(256, exception.ReportsRead);
+ Assert.Single(shortCollection.Stream.Writes);
+ Assert.Equal(256, longCollection.Stream.ReadCount);
+ }
+
+ [Fact]
+ public void Exchange_RejectsShortReadAndDisposedUse()
+ {
+ FakeCollection shortCollection = ShortCollection();
+ FakeCollection longCollection = LongCollection();
+ longCollection.Stream.Enqueue(new byte[63]);
+ Rs50OledDeviceExchange exchange = Rs50OledDeviceExchange.Open(
+ new FakeCatalog(shortCollection, longCollection));
+
+ Assert.Throws(
+ () => exchange.Exchange(Rs50OledProtocol.CreateDiscovery()));
+
+ exchange.Dispose();
+ Assert.True(shortCollection.Stream.Disposed);
+ Assert.True(longCollection.Stream.Disposed);
+ Assert.Throws(
+ () => exchange.Exchange(Rs50OledProtocol.CreateDiscovery()));
+ }
+
+ private static FakeCollection ShortCollection() =>
+ new(
+ @"\\?\hid#vid_046d&pid_c276&mi_01&col01#safe",
+ usage: 0xFF430701,
+ reportLength: 7);
+
+ private static FakeCollection LongCollection() =>
+ new(
+ @"\\?\hid#vid_046d&pid_c276&mi_01&col03#safe",
+ usage: 0xFF430704,
+ reportLength: 64);
+
+ private static byte[] DiscoveryResponse()
+ {
+ byte[] response = new byte[64];
+ response[0] = 0x12;
+ response[1] = 0xFF;
+ response[3] = 0x0A;
+ response[4] = 0x12;
+ return response;
+ }
+
+ private static byte[] LayoutResponse()
+ {
+ byte[] response = new byte[64];
+ response[0] = 0x12;
+ response[1] = 0xFF;
+ response[2] = 0x12;
+ response[3] = 0x3A;
+ return response;
+ }
+
+ private static byte[] UnrelatedResponse()
+ {
+ byte[] response = new byte[64];
+ response[0] = 0x12;
+ response[1] = 0xFF;
+ response[2] = 0x0B;
+ response[3] = 0x6E;
+ return response;
+ }
+
+ private sealed class FakeCatalog(
+ params IRs50HidCollection[] collections) : IRs50HidCatalog
+ {
+ public IReadOnlyList Enumerate() =>
+ collections;
+ }
+
+ private sealed class FakeCollection(
+ string path,
+ uint usage,
+ int reportLength) : IRs50HidCollection
+ {
+ public int VendorId { get; set; } = 0x046D;
+
+ public int ProductId { get; set; } = 0xC276;
+
+ public string DevicePath { get; set; } = path;
+
+ public IReadOnlySet Usages { get; set; } =
+ new HashSet { usage };
+
+ public int MaximumInputReportLength { get; set; } =
+ reportLength;
+
+ public int MaximumOutputReportLength { get; set; } =
+ reportLength;
+
+ public FakeStream Stream { get; } = new();
+
+ public Exception? OpenException { get; set; }
+
+ public int OpenCount { get; private set; }
+
+ public IRs50HidStream Open()
+ {
+ OpenCount++;
+ if (OpenException is not null)
+ {
+ throw OpenException;
+ }
+
+ return Stream;
+ }
+ }
+
+ private sealed class FakeStream : IRs50HidStream
+ {
+ private readonly Queue reads = new();
+
+ public List Writes { get; } = [];
+
+ public int ReadCount { get; private set; }
+
+ public bool Disposed { get; private set; }
+
+ public void Enqueue(byte[] response) =>
+ reads.Enqueue((byte[])response.Clone());
+
+ public int Read(byte[] buffer)
+ {
+ ReadCount++;
+ byte[] response = reads.Dequeue();
+ response.CopyTo(buffer, 0);
+ return response.Length;
+ }
+
+ public void Write(byte[] report) =>
+ Writes.Add((byte[])report.Clone());
+
+ public void Dispose() =>
+ Disposed = true;
+ }
+}
diff --git a/LogiDynamicDash.Tests/Rs50OledDisplaySinkTests.cs b/LogiDynamicDash.Tests/Rs50OledDisplaySinkTests.cs
new file mode 100644
index 0000000..ce24f8c
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50OledDisplaySinkTests.cs
@@ -0,0 +1,215 @@
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50OledDisplaySinkTests
+{
+ [Fact]
+ public void Initialize_OpensInjectedSessionWithoutHardwareDependency()
+ {
+ FakeSession session = new();
+ Rs50OledDisplaySink sink = CreateSink(session);
+
+ sink.Initialize();
+
+ Assert.Equal(1, session.OpenCount);
+ Assert.Equal(OledDeviceState.Active, sink.State);
+ sink.Stop();
+ Assert.True(session.Disposed);
+ Assert.Equal(OledDeviceState.Stopped, sink.State);
+ }
+
+ [Fact]
+ public void InitializeFailure_DisposesCreatedSession()
+ {
+ FakeSession session = new()
+ {
+ OpenException = new IOException("blocked")
+ };
+ Rs50OledDisplaySink sink = CreateSink(session);
+
+ Assert.Throws(() => sink.Initialize());
+ Assert.True(session.Disposed);
+ Assert.Equal(OledDeviceState.Faulted, sink.State);
+ }
+
+ [Fact]
+ public void Render_FormatsAndSendsStationaryTelemetry()
+ {
+ FakeSession session = new();
+ Rs50OledDisplaySink sink = CreateSink(session);
+ sink.Initialize();
+ TelemetrySnapshot snapshot = ConnectedSnapshot();
+
+ sink.Render(snapshot, DisplayMode.Normal);
+
+ Rs50LayoutEFrame frame =
+ Assert.IsType(Assert.Single(session.Frames));
+ Assert.Equal("0 KMH", frame.LeftText);
+ Assert.Equal("N", frame.RightText);
+ }
+
+ [Theory]
+ [InlineData(0.5001f)]
+ [InlineData(-0.1f)]
+ [InlineData(float.NaN)]
+ public void Render_FailsBeforeSendWhenOnTrackTelemetryIsNotStationary(
+ float speed)
+ {
+ FakeSession session = new();
+ Rs50OledDisplaySink sink = CreateSink(session);
+ sink.Initialize();
+ TelemetrySnapshot snapshot = ConnectedSnapshot();
+ snapshot.SpeedMetersPerSecond = speed;
+
+ Assert.Throws(
+ () => sink.Render(snapshot, DisplayMode.Normal));
+ Assert.Empty(session.Frames);
+ Assert.Equal(OledDeviceState.Faulted, sink.State);
+ }
+
+ [Fact]
+ public void ExplicitLowSpeedEnvelope_AcceptsBelowAndRejectsAboveLimit()
+ {
+ FakeSession session = new();
+ Rs50OledDisplaySink sink = new(
+ () => session,
+ new Rs50TelemetryFrameFormatter(
+ new Rs50OledConfiguration(Rs50OledLayout.E)),
+ maximumPermittedSpeedMetersPerSecond: 20f / 3.6f);
+ sink.Initialize();
+ TelemetrySnapshot snapshot = ConnectedSnapshot();
+ snapshot.SpeedMetersPerSecond = 5;
+
+ sink.Render(snapshot, DisplayMode.Normal);
+
+ snapshot.SpeedMetersPerSecond = 6;
+ Assert.Throws(
+ () => sink.Render(snapshot, DisplayMode.Normal));
+ Assert.Single(session.Frames);
+ Assert.Equal(OledDeviceState.Faulted, sink.State);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(float.NaN)]
+ public void InvalidSpeedEnvelope_IsRejected(float maximumSpeed)
+ {
+ Assert.Throws(
+ () => new Rs50OledDisplaySink(
+ () => new FakeSession(),
+ new Rs50TelemetryFrameFormatter(
+ new Rs50OledConfiguration(Rs50OledLayout.E)),
+ maximumSpeed));
+ }
+
+ [Fact]
+ public void NoSpeedLimit_AcceptsFiniteMovingTelemetry()
+ {
+ FakeSession session = new();
+ Rs50OledDisplaySink sink = new(
+ () => session,
+ new Rs50TelemetryFrameFormatter(
+ new Rs50OledConfiguration(Rs50OledLayout.E)),
+ maximumPermittedSpeedMetersPerSecond: null);
+ sink.Initialize();
+ TelemetrySnapshot snapshot = ConnectedSnapshot();
+ snapshot.SpeedMetersPerSecond = 120;
+
+ sink.Render(snapshot, DisplayMode.Normal);
+
+ Assert.Single(session.Frames);
+ Assert.Equal(OledDeviceState.Active, sink.State);
+ sink.Stop();
+ }
+
+ [Fact]
+ public void RenderFailure_FaultsAndDoesNotReopenOrRetry()
+ {
+ FakeSession session = new()
+ {
+ SendException = new IOException("injected")
+ };
+ Rs50OledDisplaySink sink = CreateSink(session);
+ sink.Initialize();
+
+ Assert.Throws(
+ () => sink.Render(ConnectedSnapshot(), DisplayMode.Normal));
+ Assert.Equal(OledDeviceState.Faulted, sink.State);
+ Assert.Throws(
+ () => sink.Render(ConnectedSnapshot(), DisplayMode.Normal));
+ Assert.Throws(() => sink.Initialize());
+ Assert.Single(session.Frames);
+ }
+
+ [Fact]
+ public void Stop_IsIdempotentAndRejectsFurtherRendering()
+ {
+ FakeSession session = new();
+ Rs50OledDisplaySink sink = CreateSink(session);
+ sink.Initialize();
+
+ sink.Stop();
+ sink.Stop();
+
+ Assert.Equal(1, session.DisposeCount);
+ Assert.Throws(
+ () => sink.Render(ConnectedSnapshot(), DisplayMode.Normal));
+ }
+
+ private static Rs50OledDisplaySink CreateSink(FakeSession session) =>
+ new(
+ () => session,
+ new Rs50TelemetryFrameFormatter(
+ new Rs50OledConfiguration(Rs50OledLayout.E)));
+
+ private static TelemetrySnapshot ConnectedSnapshot() =>
+ new()
+ {
+ ConnectionState = "CONNECTED",
+ IsOnTrack = true,
+ SpeedMetersPerSecond = 0,
+ Gear = 0,
+ Rpm = 1000
+ };
+
+ private sealed class FakeSession : IRs50OledSession
+ {
+ public List Frames { get; } = [];
+
+ public Exception? OpenException { get; set; }
+ public Exception? SendException { get; set; }
+
+ public int OpenCount { get; private set; }
+
+ public int DisposeCount { get; private set; }
+
+ public bool Disposed => DisposeCount != 0;
+
+ public void Open()
+ {
+ OpenCount++;
+ if (OpenException is not null)
+ {
+ throw OpenException;
+ }
+ }
+
+ public Rs50OledSendResult Send(Rs50OledFrame frame)
+ {
+ Frames.Add(frame);
+ if (SendException is not null)
+ {
+ throw SendException;
+ }
+
+ return Rs50OledSendResult.Transmitted;
+ }
+
+ public void Dispose() =>
+ DisposeCount++;
+ }
+}
diff --git a/LogiDynamicDash.Tests/Rs50OledFrameSchedulerTests.cs b/LogiDynamicDash.Tests/Rs50OledFrameSchedulerTests.cs
new file mode 100644
index 0000000..44efc20
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50OledFrameSchedulerTests.cs
@@ -0,0 +1,120 @@
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50OledFrameSchedulerTests
+{
+ [Fact]
+ public void Submit_RetainsLatestOrdinaryFrameUntilRateLimitClears()
+ {
+ FakeSession session = new(
+ Rs50OledSendResult.RateLimited,
+ Rs50OledSendResult.RateLimited,
+ Rs50OledSendResult.Transmitted);
+ Rs50OledFrameScheduler scheduler = new(session);
+ Rs50OledFrame first = new Rs50LayoutFFrame("1", "10");
+ Rs50OledFrame latest = new Rs50LayoutFFrame("2", "20");
+
+ scheduler.Submit(first, isCritical: false);
+ scheduler.Submit(latest, isCritical: false);
+ scheduler.Submit(latest, isCritical: false);
+
+ Assert.Equal([first, first, latest], session.Frames);
+ Assert.False(scheduler.HasPendingFrame);
+ }
+
+ [Fact]
+ public void Submit_DoesNotReplacePendingCriticalFrameWithTelemetry()
+ {
+ FakeSession session = new(
+ Rs50OledSendResult.RateLimited,
+ Rs50OledSendResult.RateLimited,
+ Rs50OledSendResult.Transmitted,
+ Rs50OledSendResult.RateLimited);
+ Rs50OledFrameScheduler scheduler = new(session);
+ Rs50OledFrame critical = new Rs50LayoutHFrame("IRACING", "ERROR");
+ Rs50OledFrame normal = new Rs50LayoutHFrame("SPEED 0 KMH", "GEAR N");
+
+ scheduler.Submit(critical, isCritical: true);
+ scheduler.Submit(normal, isCritical: false);
+ scheduler.Submit(normal, isCritical: false);
+
+ Assert.Equal([critical, critical, critical, normal], session.Frames);
+ Assert.True(scheduler.HasPendingFrame);
+ }
+
+ [Fact]
+ public void Submit_RemainsSingleConsumerAcrossOneMillionVirtualUpdates()
+ {
+ CountingSession session = new();
+ Rs50OledFrameScheduler scheduler = new(session);
+ Rs50OledFrame frame = new Rs50LayoutFFrame("6", "299");
+
+ for (int index = 0; index < 1_000_000; index++)
+ {
+ scheduler.Submit(frame, isCritical: false);
+ }
+
+ Assert.Equal(1_000_000, session.SendCount);
+ Assert.False(scheduler.HasPendingFrame);
+ }
+
+ [Fact]
+ public void Flush_DeliversPendingCriticalFrameWithoutAnotherSubmission()
+ {
+ FakeSession session = new(
+ Rs50OledSendResult.RateLimited,
+ Rs50OledSendResult.Transmitted);
+ Rs50OledFrameScheduler scheduler = new(session);
+ Rs50OledFrame critical = new Rs50LayoutHFrame("IRACING", "ERROR");
+
+ scheduler.Submit(critical, isCritical: true);
+ scheduler.Flush();
+
+ Assert.Equal([critical, critical], session.Frames);
+ Assert.False(scheduler.HasPendingFrame);
+ }
+
+ private sealed class FakeSession(params Rs50OledSendResult[] results)
+ : IRs50OledSession
+ {
+ private readonly Queue results = new(results);
+
+ public List Frames { get; } = [];
+
+ public void Open()
+ {
+ }
+
+ public Rs50OledSendResult Send(Rs50OledFrame frame)
+ {
+ Frames.Add(frame);
+ return results.Dequeue();
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+
+ private sealed class CountingSession : IRs50OledSession
+ {
+ public int SendCount { get; private set; }
+
+ public void Open()
+ {
+ }
+
+ public Rs50OledSendResult Send(Rs50OledFrame frame)
+ {
+ SendCount++;
+ return Rs50OledSendResult.Unchanged;
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/LogiDynamicDash.Tests/Rs50OledPreviewRunnerTests.cs b/LogiDynamicDash.Tests/Rs50OledPreviewRunnerTests.cs
new file mode 100644
index 0000000..446ca09
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50OledPreviewRunnerTests.cs
@@ -0,0 +1,37 @@
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Offline;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50OledPreviewRunnerTests
+{
+ [Fact]
+ public void RunAll_DescribesEveryLayoutAndModeWithoutHid()
+ {
+ StringWriter output = new();
+
+ Rs50OledPreviewRunner.RunAll(
+ new Rs50OledConfiguration(Rs50OledLayout.E),
+ output);
+
+ string text = output.ToString();
+ foreach (char layout in "ABCDEFGHIJ")
+ {
+ Assert.Contains($"LAYOUT {layout}", text);
+ }
+
+ Assert.Equal(
+ 10,
+ text.Split("LAYOUT ", StringSplitOptions.None).Length - 1);
+ Assert.Equal(
+ 40,
+ text.Split("Normal:", StringSplitOptions.None).Length -
+ 1 +
+ text.Split("BrakeBias:", StringSplitOptions.None).Length -
+ 1 +
+ text.Split("LastLap:", StringSplitOptions.None).Length -
+ 1 +
+ text.Split("ConnectionProblem:", StringSplitOptions.None).Length -
+ 1);
+ }
+}
diff --git a/LogiDynamicDash.Tests/Rs50OledProtocolTests.cs b/LogiDynamicDash.Tests/Rs50OledProtocolTests.cs
new file mode 100644
index 0000000..9ce6a19
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50OledProtocolTests.cs
@@ -0,0 +1,230 @@
+using System.Text;
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50OledProtocolTests
+{
+ [Fact]
+ public void Discovery_UsesOnlyRootGetFeatureForDisplayGameData()
+ {
+ Rs50OledTransaction transaction =
+ Rs50OledProtocol.CreateDiscovery();
+
+ Assert.Equal(
+ Rs50OledTransactionKind.DiscoverDisplayFeature,
+ transaction.Kind);
+ Assert.Equal(
+ [0x10, 0xFF, 0x00, 0x0A, 0x81, 0x30, 0x00],
+ transaction.Request.ToArray());
+ }
+
+ [Fact]
+ public void DiscoveryResponse_ReturnsCapturedRuntimeIndex()
+ {
+ byte[] response = Response(featureIndex: 0, function: 0x0A);
+ response[4] = 0x12;
+
+ Assert.Equal(
+ 0x12,
+ Rs50OledProtocol.ParseDiscoveryResponse(response));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(255)]
+ public void DiscoveryResponse_RejectsInvalidRuntimeIndex(byte value)
+ {
+ byte[] response = Response(featureIndex: 0, function: 0x0A);
+ response[4] = value;
+
+ Assert.Throws(
+ () => Rs50OledProtocol.ParseDiscoveryResponse(response));
+ }
+
+ [Fact]
+ public void GaugeLevel_ClampsAndRoundsToWireByte()
+ {
+ Assert.Equal(0, Rs50GaugeLevel.FromRatio(-1).WireValue);
+ Assert.Equal(128, Rs50GaugeLevel.FromRatio(0.5).WireValue);
+ Assert.Equal(255, Rs50GaugeLevel.FromRatio(2).WireValue);
+ Assert.Throws(
+ () => Rs50GaugeLevel.FromRatio(double.NaN));
+ }
+
+ [Fact]
+ public void LayoutsAThroughJ_UseOnlyConfirmedLayoutIndices()
+ {
+ Rs50GaugeLevel low = Rs50GaugeLevel.FromRatio(0.25);
+ Rs50GaugeLevel high = Rs50GaugeLevel.FromRatio(0.75);
+ Rs50OledFrame[] frames =
+ [
+ new Rs50LayoutAFrame(),
+ new Rs50LayoutBFrame(),
+ new Rs50LayoutCFrame(low),
+ new Rs50LayoutDFrame(low, high, "LAYOUT D"),
+ new Rs50LayoutEFrame(low, high, "LAYOUTE", "E1"),
+ new Rs50LayoutFFrame("F", "123"),
+ new Rs50LayoutGFrame("G", "456"),
+ new Rs50LayoutHFrame("LAYOUT H", "SECOND"),
+ new Rs50LayoutIFrame("I1", "I2", "I3", "I4"),
+ new Rs50LayoutJFrame("J1", "J2", "J3", "J4")
+ ];
+
+ for (int index = 0; index < frames.Length; index++)
+ {
+ byte[] request =
+ Rs50OledProtocol.CreateLayout(0x12, frames[index])
+ .Request
+ .ToArray();
+
+ Assert.Equal(64, request.Length);
+ Assert.Equal([0x12, 0xFF, 0x12, 0x3A], request[..4]);
+ Assert.Equal(index, request[4]);
+ }
+ }
+
+ [Fact]
+ public void LayoutE_EncodesVisualRightFieldBeforeVisualLeftField()
+ {
+ Rs50LayoutEFrame frame = new(
+ Rs50GaugeLevel.FromRatio(64d / 255d),
+ Rs50GaugeLevel.FromRatio(191d / 255d),
+ LeftText: "123 KMH",
+ RightText: "N");
+
+ byte[] request =
+ Rs50OledProtocol.CreateLayout(0x12, frame).Request.ToArray();
+
+ Assert.Equal(64, request[5]);
+ Assert.Equal(191, request[6]);
+ Assert.Equal("N", ReadText(request, 7, 3));
+ Assert.Equal("123 KMH", ReadText(request, 10, 7));
+ }
+
+ [Fact]
+ public void LayoutJ_MatchesAcceptedStationaryTelemetryFrame()
+ {
+ Rs50LayoutJFrame frame =
+ new("SPEED", "0 KMH", "GEAR", "N");
+
+ Rs50OledTransaction transaction =
+ Rs50OledProtocol.CreateLayout(0x12, frame);
+ byte[] request = transaction.Request.ToArray();
+
+ Assert.Equal(
+ Rs50OledTransactionKind.SetLayoutJ,
+ transaction.Kind);
+ Assert.Equal("SPEED", ReadText(request, 5, 19));
+ Assert.Equal("0 KMH", ReadText(request, 24, 10));
+ Assert.Equal("GEAR", ReadText(request, 34, 19));
+ Assert.Equal("N", ReadText(request, 53, 10));
+ Assert.Equal(0, request[63]);
+ }
+
+ [Fact]
+ public void TextFields_RejectOverflowAndUnsupportedCharacters()
+ {
+ Assert.Throws(
+ () => Rs50OledProtocol.CreateLayout(
+ 0x12,
+ new Rs50LayoutFFrame("AB", "123")));
+ Assert.Throws(
+ () => Rs50OledProtocol.CreateLayout(
+ 0x12,
+ new Rs50LayoutHFrame(new string('A', 22), "")));
+ Assert.Throws(
+ () => Rs50OledProtocol.CreateLayout(
+ 0x12,
+ new Rs50LayoutJFrame("", "", "", "KM/H \u00E9")));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(255)]
+ public void Layout_RejectsRuntimeIndexThatWasNotDiscoverable(byte value)
+ {
+ Assert.Throws(
+ () => Rs50OledProtocol.CreateLayout(
+ value,
+ new Rs50LayoutAFrame()));
+ }
+
+ [Fact]
+ public void LayoutAcknowledgement_RequiresExactHeaderAndZeroBody()
+ {
+ Rs50OledTransaction transaction =
+ Rs50OledProtocol.CreateLayout(
+ 0x12,
+ new Rs50LayoutJFrame("SPEED", "0 KMH", "GEAR", "N"));
+ byte[] valid = Response(featureIndex: 0x12, function: 0x3A);
+
+ Rs50OledProtocol.ParseLayoutAcknowledgement(transaction, valid);
+
+ valid[63] = 1;
+ Assert.Throws(
+ () => Rs50OledProtocol.ParseLayoutAcknowledgement(
+ transaction,
+ valid));
+ }
+
+ [Fact]
+ public void LayoutAcknowledgement_RejectsHidppError()
+ {
+ Rs50OledTransaction transaction =
+ Rs50OledProtocol.CreateLayout(
+ 0x12,
+ new Rs50LayoutAFrame());
+ byte[] error = Response(featureIndex: 0xFF, function: 0x0A);
+ error[4] = 0x12;
+ error[5] = 0x3A;
+ error[6] = 0x08;
+
+ Assert.Throws(
+ () => Rs50OledProtocol.ParseLayoutAcknowledgement(
+ transaction,
+ error));
+ }
+
+ [Fact]
+ public void TransactionRequest_DoesNotExposeMutableStorage()
+ {
+ Rs50OledTransaction transaction =
+ Rs50OledProtocol.CreateLayout(
+ 0x12,
+ new Rs50LayoutAFrame());
+
+ byte[] first = transaction.Request.ToArray();
+ first[2] = 0x99;
+
+ Assert.Equal(0x12, transaction.Request.Span[2]);
+ }
+
+ private static byte[] Response(byte featureIndex, byte function)
+ {
+ byte[] response = new byte[64];
+ response[0] = 0x12;
+ response[1] = 0xFF;
+ response[2] = featureIndex;
+ response[3] = function;
+ return response;
+ }
+
+ private static string ReadText(
+ byte[] report,
+ int offset,
+ int length)
+ {
+ ReadOnlySpan field = report.AsSpan(offset, length);
+ int terminator = field.IndexOf((byte)0);
+ if (terminator >= 0)
+ {
+ field = field[..terminator];
+ }
+
+ return Encoding.ASCII.GetString(field);
+ }
+}
diff --git a/LogiDynamicDash.Tests/Rs50OledSessionTests.cs b/LogiDynamicDash.Tests/Rs50OledSessionTests.cs
new file mode 100644
index 0000000..445ed9d
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50OledSessionTests.cs
@@ -0,0 +1,261 @@
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Hidpp.Transport;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50OledSessionTests
+{
+ [Fact]
+ public void Open_DiscoversFeatureExactlyOnce()
+ {
+ FakeExchange exchange = new();
+ exchange.Enqueue(DiscoveryResponse());
+ using Rs50OledSession session = new(exchange);
+
+ session.Open();
+
+ Assert.Single(exchange.Transactions);
+ Assert.Equal(
+ Rs50OledTransactionKind.DiscoverDisplayFeature,
+ exchange.Transactions[0].Kind);
+ Assert.Throws(() => session.Open());
+ }
+
+ [Fact]
+ public void Send_RequiresOpenSession()
+ {
+ using Rs50OledSession session = new(new FakeExchange());
+
+ Assert.Throws(
+ () => session.Send(new Rs50LayoutAFrame()));
+ }
+
+ [Fact]
+ public void Send_TransmitsAnyTypedLayoutAndValidatesAcknowledgement()
+ {
+ FakeExchange exchange = new();
+ exchange.Enqueue(DiscoveryResponse());
+ exchange.Enqueue(LayoutResponse());
+ using Rs50OledSession session = new(exchange);
+ session.Open();
+
+ Rs50OledSendResult result =
+ session.Send(new Rs50LayoutEFrame(
+ Rs50GaugeLevel.FromRatio(0.5),
+ Rs50GaugeLevel.FromRatio(0.25),
+ "99 KMH",
+ "3"));
+
+ Assert.Equal(Rs50OledSendResult.Transmitted, result);
+ Assert.Equal(2, exchange.Transactions.Count);
+ Assert.Equal(
+ Rs50OledTransactionKind.SetLayoutE,
+ exchange.Transactions[1].Kind);
+ }
+
+ [Fact]
+ public void Send_SuppressesLastAcknowledgedFrame()
+ {
+ FakeExchange exchange = new();
+ exchange.Enqueue(DiscoveryResponse());
+ exchange.Enqueue(LayoutResponse());
+ using Rs50OledSession session = new(exchange);
+ session.Open();
+ Rs50LayoutJFrame frame =
+ new("SPEED", "0 KMH", "GEAR", "N");
+
+ Assert.Equal(
+ Rs50OledSendResult.Transmitted,
+ session.Send(frame));
+ Assert.Equal(
+ Rs50OledSendResult.Unchanged,
+ session.Send(frame));
+ Assert.Equal(2, exchange.Transactions.Count);
+ }
+
+ [Fact]
+ public void Send_RateLimitsChangedFramesToFiveHertz()
+ {
+ ManualTimeProvider clock = new();
+ FakeExchange exchange = new();
+ exchange.Enqueue(DiscoveryResponse());
+ exchange.Enqueue(LayoutResponse());
+ exchange.Enqueue(LayoutResponse());
+ using Rs50OledSession session = new(exchange, clock);
+ session.Open();
+
+ Assert.Equal(
+ Rs50OledSendResult.Transmitted,
+ session.Send(new Rs50LayoutFFrame("N", "000")));
+
+ clock.Advance(TimeSpan.FromMilliseconds(199));
+ Assert.Equal(
+ Rs50OledSendResult.RateLimited,
+ session.Send(new Rs50LayoutFFrame("1", "001")));
+ Assert.Equal(2, exchange.Transactions.Count);
+
+ clock.Advance(TimeSpan.FromMilliseconds(1));
+ Assert.Equal(
+ Rs50OledSendResult.Transmitted,
+ session.Send(new Rs50LayoutFFrame("1", "001")));
+ Assert.Equal(3, exchange.Transactions.Count);
+ }
+
+ [Fact]
+ public void ProtocolFailure_PermanentlyFaultsSession()
+ {
+ FakeExchange exchange = new();
+ exchange.Enqueue(DiscoveryResponse());
+ byte[] invalidAcknowledgement = LayoutResponse();
+ invalidAcknowledgement[63] = 1;
+ exchange.Enqueue(invalidAcknowledgement);
+ using Rs50OledSession session = new(exchange);
+ session.Open();
+
+ Assert.Throws(
+ () => session.Send(new Rs50LayoutAFrame()));
+ Assert.Throws(
+ () => session.Send(new Rs50LayoutAFrame()));
+ Assert.Equal(2, exchange.Transactions.Count);
+ }
+
+ [Fact]
+ public void MissingLayoutAcknowledgement_ContinuesWithoutRetryingFrame()
+ {
+ ManualTimeProvider clock = new();
+ FakeExchange exchange = new();
+ exchange.Enqueue(DiscoveryResponse());
+ using Rs50OledSession session = new(exchange, clock);
+ session.Open();
+ Rs50LayoutFFrame unacknowledged = new("1", "100");
+ exchange.Exception =
+ new Rs50OledAcknowledgementTimeoutException(5);
+
+ Assert.Equal(
+ Rs50OledSendResult.Unacknowledged,
+ session.Send(unacknowledged));
+ Assert.Equal(
+ Rs50OledSendResult.Unchanged,
+ session.Send(unacknowledged));
+ Assert.Equal(2, exchange.Transactions.Count);
+
+ clock.Advance(TimeSpan.FromMilliseconds(200));
+ exchange.Exception = null;
+ exchange.Enqueue(LayoutResponse());
+ Assert.Equal(
+ Rs50OledSendResult.Transmitted,
+ session.Send(new Rs50LayoutFFrame("2", "120")));
+ Assert.Equal(3, exchange.Transactions.Count);
+ }
+
+ [Fact]
+ public void OtherTransportFailure_PermanentlyFaultsSession()
+ {
+ FakeExchange exchange = new();
+ exchange.Enqueue(DiscoveryResponse());
+ using Rs50OledSession session = new(exchange);
+ session.Open();
+ exchange.Exception = new IOException("device unavailable");
+
+ Assert.Throws(
+ () => session.Send(new Rs50LayoutAFrame()));
+
+ exchange.Exception = null;
+ exchange.Enqueue(LayoutResponse());
+ Assert.Throws(
+ () => session.Send(new Rs50LayoutAFrame()));
+ Assert.Equal(2, exchange.Transactions.Count);
+ }
+
+ [Fact]
+ public void DiscoveryFailure_PermanentlyFaultsSession()
+ {
+ FakeExchange exchange = new();
+ exchange.Exception =
+ new IOException("The device is unavailable.");
+ using Rs50OledSession session = new(exchange);
+
+ Assert.Throws(() => session.Open());
+ exchange.Exception = null;
+ Assert.Throws(() => session.Open());
+ Assert.Single(exchange.Transactions);
+ }
+
+ [Fact]
+ public void Dispose_ClosesExchangeAndRejectsFurtherUse()
+ {
+ FakeExchange exchange = new();
+ exchange.Enqueue(DiscoveryResponse());
+ Rs50OledSession session = new(exchange);
+ session.Open();
+
+ session.Dispose();
+ session.Dispose();
+
+ Assert.True(exchange.Disposed);
+ Assert.Throws(() => session.Open());
+ Assert.Throws(
+ () => session.Send(new Rs50LayoutAFrame()));
+ }
+
+ private static byte[] DiscoveryResponse()
+ {
+ byte[] response = new byte[64];
+ response[0] = 0x12;
+ response[1] = 0xFF;
+ response[3] = 0x0A;
+ response[4] = 0x12;
+ return response;
+ }
+
+ private static byte[] LayoutResponse()
+ {
+ byte[] response = new byte[64];
+ response[0] = 0x12;
+ response[1] = 0xFF;
+ response[2] = 0x12;
+ response[3] = 0x3A;
+ return response;
+ }
+
+ private sealed class FakeExchange : IRs50OledExchange
+ {
+ private readonly Queue responses = new();
+
+ public List Transactions { get; } = [];
+
+ public Exception? Exception { get; set; }
+
+ public bool Disposed { get; private set; }
+
+ public void Enqueue(byte[] response) =>
+ responses.Enqueue((byte[])response.Clone());
+
+ public byte[] Exchange(Rs50OledTransaction transaction)
+ {
+ Transactions.Add(transaction);
+ if (Exception is not null)
+ {
+ throw Exception;
+ }
+
+ return responses.Dequeue();
+ }
+
+ public void Dispose() =>
+ Disposed = true;
+ }
+
+ private sealed class ManualTimeProvider : TimeProvider
+ {
+ private long timestamp;
+
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+
+ public override long GetTimestamp() => timestamp;
+
+ public void Advance(TimeSpan duration) =>
+ timestamp += duration.Ticks;
+ }
+}
diff --git a/LogiDynamicDash.Tests/Rs50OledSimulationRunnerTests.cs b/LogiDynamicDash.Tests/Rs50OledSimulationRunnerTests.cs
new file mode 100644
index 0000000..850374c
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50OledSimulationRunnerTests.cs
@@ -0,0 +1,82 @@
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Offline;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50OledSimulationRunnerTests
+{
+ [Fact]
+ public void RunAll_ExercisesEveryLayoutThroughProtocolSession()
+ {
+ StringWriter output = new();
+
+ IReadOnlyList results =
+ Rs50OledSimulationRunner.RunAll(
+ new Rs50OledConfiguration(Rs50OledLayout.E),
+ output);
+
+ Assert.Equal(10, results.Count);
+ Assert.Equal(
+ Enum.GetValues(),
+ results.Select(result => result.Layout));
+
+ foreach (Rs50OledSimulationResult result in results)
+ {
+ Assert.Equal(601, result.Updates);
+ Assert.Equal(1, result.DiscoveryTransactions);
+ Assert.Equal(result.Transmitted, result.LayoutTransactions);
+ Assert.InRange(result.Transmitted, 1, 151);
+ Assert.Equal(
+ result.Updates,
+ result.Transmitted +
+ result.Unchanged +
+ result.RateLimited);
+ }
+
+ Assert.Contains("LAYOUT A:", output.ToString());
+ Assert.Contains("LAYOUT J:", output.ToString());
+ }
+
+ [Fact]
+ public void Session_RemainsBoundedAcrossTwentyThousandVirtualUpdates()
+ {
+ ManualTimeProvider clock = new();
+ SimulatedRs50OledExchange exchange = new();
+ using Rs50OledSession session = new(exchange, clock);
+ session.Open();
+ int transmitted = 0;
+
+ for (int index = 0; index < 20_000; index++)
+ {
+ Rs50OledSendResult result = session.Send(
+ new Rs50LayoutJFrame(
+ "SPEED",
+ $"{index % 1000} KMH",
+ "GEAR",
+ $"{index % 9 + 1}"));
+ if (result == Rs50OledSendResult.Transmitted)
+ {
+ transmitted++;
+ }
+
+ clock.Advance(TimeSpan.FromMilliseconds(10));
+ }
+
+ Assert.Equal(transmitted, exchange.LayoutCount);
+ Assert.InRange(transmitted, 999, 1000);
+ Assert.Equal(1, exchange.DiscoveryCount);
+ }
+
+ private sealed class ManualTimeProvider : TimeProvider
+ {
+ private long timestamp;
+
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+
+ public override long GetTimestamp() => timestamp;
+
+ internal void Advance(TimeSpan duration) =>
+ timestamp += duration.Ticks;
+ }
+}
diff --git a/LogiDynamicDash.Tests/Rs50ProductionRunOptionsTests.cs b/LogiDynamicDash.Tests/Rs50ProductionRunOptionsTests.cs
new file mode 100644
index 0000000..9a038d7
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50ProductionRunOptionsTests.cs
@@ -0,0 +1,63 @@
+using LogiDynamicDash.Configuration;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50ProductionRunOptionsTests
+{
+ [Fact]
+ public void MinimalArguments_EnableAutomaticProductionRuntime()
+ {
+ Assert.True(
+ Rs50ProductionRunOptions.TryParse(
+ ["--run-rs50-oled", "--config", "dash.json"],
+ out Rs50ProductionRunOptions? options));
+
+ Assert.NotNull(options);
+ Assert.True(options.AutomaticProfiles);
+ Assert.Equal(5, options.LastLapDisplaySeconds);
+ Assert.EndsWith(
+ Path.Combine("LogiDynamicDash", "profiles"),
+ options.ProfileDirectory);
+ }
+
+ [Fact]
+ public void OptionalArguments_SelectManualProfileAndDuration()
+ {
+ Assert.True(
+ Rs50ProductionRunOptions.TryParse(
+ [
+ "--run-rs50-oled",
+ "--config",
+ "dash.json",
+ "--profiles",
+ "profiles",
+ "--manual-profile",
+ "--last-lap-seconds",
+ "8.5"
+ ],
+ out Rs50ProductionRunOptions? options));
+
+ Assert.NotNull(options);
+ Assert.False(options.AutomaticProfiles);
+ Assert.Equal("profiles", options.ProfileDirectory);
+ Assert.Equal(8.5, options.LastLapDisplaySeconds);
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("16")]
+ [InlineData("NaN")]
+ public void InvalidLastLapDuration_IsRejected(string value)
+ {
+ Assert.False(
+ Rs50ProductionRunOptions.TryParse(
+ [
+ "--run-rs50-oled",
+ "--config",
+ "dash.json",
+ "--last-lap-seconds",
+ value
+ ],
+ out _));
+ }
+}
diff --git a/LogiDynamicDash.Tests/Rs50ProfileStoreTests.cs b/LogiDynamicDash.Tests/Rs50ProfileStoreTests.cs
new file mode 100644
index 0000000..c05de7b
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50ProfileStoreTests.cs
@@ -0,0 +1,108 @@
+using LogiDynamicDash.Configuration;
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50ProfileStoreTests
+{
+ [Fact]
+ public void CarProfile_TakesPriorityOverDisciplineProfile()
+ {
+ using TemporaryDirectory directory = new();
+ Rs50ProfileStore store = new(directory.Path);
+ store.SaveForDiscipline(
+ IRacingDiscipline.FormulaCar,
+ new Rs50OledConfiguration(Rs50OledLayout.D));
+ store.SaveForCar(
+ 42,
+ new Rs50OledConfiguration(Rs50OledLayout.J));
+
+ Rs50OledConfiguration? resolved = store.Resolve(
+ Identity(IRacingDiscipline.FormulaCar, 42));
+
+ Assert.NotNull(resolved);
+ Assert.Equal(Rs50OledLayout.J, resolved.Layout);
+ }
+
+ [Fact]
+ public void AutomaticFormatter_UsesRecommendationWithoutStoredOverride()
+ {
+ using TemporaryDirectory directory = new();
+ AutomaticRs50TelemetryFrameFormatter formatter = new(
+ new Rs50OledConfiguration(Rs50OledLayout.D),
+ new Rs50ProfileStore(directory.Path));
+ TelemetrySnapshot snapshot = new()
+ {
+ SessionIdentity = Identity(IRacingDiscipline.FormulaCar, 42),
+ ConnectionState = "CONNECTED",
+ SpeedMetersPerSecond = 0,
+ Gear = 0,
+ Rpm = 1000
+ };
+
+ Rs50OledFrame frame = formatter.Format(
+ snapshot,
+ DisplayMode.Normal);
+
+ Assert.IsType(frame);
+ }
+
+ [Fact]
+ public void ManualFormatter_AlwaysUsesFallback()
+ {
+ using TemporaryDirectory directory = new();
+ AutomaticRs50TelemetryFrameFormatter formatter = new(
+ new Rs50OledConfiguration(Rs50OledLayout.D),
+ new Rs50ProfileStore(directory.Path),
+ automaticProfiles: false);
+ TelemetrySnapshot snapshot = new()
+ {
+ SessionIdentity = Identity(IRacingDiscipline.FormulaCar, 42),
+ ConnectionState = "CONNECTED",
+ SpeedMetersPerSecond = 0,
+ Gear = 0,
+ Rpm = 1000
+ };
+
+ Assert.IsType(
+ formatter.Format(snapshot, DisplayMode.Normal));
+ }
+
+ private static IRacingSessionIdentity Identity(
+ IRacingDiscipline discipline,
+ int carId) =>
+ new(
+ discipline,
+ discipline.ToString(),
+ "Road",
+ new CarIdentity(
+ carId,
+ "cars/test",
+ "Test Car",
+ "Test",
+ 1,
+ "Test Class",
+ false));
+
+ private sealed class TemporaryDirectory : IDisposable
+ {
+ internal TemporaryDirectory()
+ {
+ Path = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ "LogiDynamicDash.Tests",
+ Guid.NewGuid().ToString("N"));
+ }
+
+ internal string Path { get; }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(Path))
+ {
+ Directory.Delete(Path, recursive: true);
+ }
+ }
+ }
+}
diff --git a/LogiDynamicDash.Tests/Rs50StationaryTrialOptionsTests.cs b/LogiDynamicDash.Tests/Rs50StationaryTrialOptionsTests.cs
new file mode 100644
index 0000000..56e73b3
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50StationaryTrialOptionsTests.cs
@@ -0,0 +1,58 @@
+using LogiDynamicDash.Configuration;
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50StationaryTrialOptionsTests
+{
+ [Fact]
+ public void TryParse_AcceptsOnlyCompleteOrderedArmingContract()
+ {
+ Assert.True(
+ Rs50StationaryTrialOptions.TryParse(
+ ValidArguments(),
+ out Rs50StationaryTrialOptions? options));
+
+ Assert.NotNull(options);
+ Assert.Equal("settings.json", options.ConfigurationPath);
+ }
+
+ [Fact]
+ public void TryParse_RejectsMissingReorderedOrExtraArgument()
+ {
+ string[] missing = ValidArguments()[..^1];
+ Assert.False(
+ Rs50StationaryTrialOptions.TryParse(missing, out _));
+
+ string[] reordered = ValidArguments();
+ (reordered[1], reordered[2]) = (reordered[2], reordered[1]);
+ Assert.False(
+ Rs50StationaryTrialOptions.TryParse(reordered, out _));
+
+ string[] extra = [.. ValidArguments(), "--extra"];
+ Assert.False(
+ Rs50StationaryTrialOptions.TryParse(extra, out _));
+ }
+
+ [Fact]
+ public void TryParse_RejectsEmptyConfigurationPath()
+ {
+ string[] arguments = ValidArguments();
+ arguments[8] = " ";
+
+ Assert.False(
+ Rs50StationaryTrialOptions.TryParse(arguments, out _));
+ }
+
+ internal static string[] ValidArguments() =>
+ [
+ "--enable-rs50-oled-stationary-trial",
+ "--confirm-ghub-closed",
+ "--confirm-iracing-running",
+ "--confirm-car-stationary-in-pits",
+ "--confirm-rs50-dynamic-selected",
+ "--confirm-10-second-limit",
+ "--acknowledge-no-moving-car-use",
+ "--config",
+ "settings.json",
+ "--confirm-settings"
+ ];
+}
diff --git a/LogiDynamicDash.Tests/Rs50TelemetryFrameFormatterTests.cs b/LogiDynamicDash.Tests/Rs50TelemetryFrameFormatterTests.cs
new file mode 100644
index 0000000..7897886
--- /dev/null
+++ b/LogiDynamicDash.Tests/Rs50TelemetryFrameFormatterTests.cs
@@ -0,0 +1,150 @@
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class Rs50TelemetryFrameFormatterTests
+{
+ [Theory]
+ [InlineData(0, typeof(Rs50LayoutAFrame))]
+ [InlineData(1, typeof(Rs50LayoutBFrame))]
+ [InlineData(2, typeof(Rs50LayoutCFrame))]
+ [InlineData(3, typeof(Rs50LayoutDFrame))]
+ [InlineData(4, typeof(Rs50LayoutEFrame))]
+ [InlineData(5, typeof(Rs50LayoutFFrame))]
+ [InlineData(6, typeof(Rs50LayoutGFrame))]
+ [InlineData(7, typeof(Rs50LayoutHFrame))]
+ [InlineData(8, typeof(Rs50LayoutIFrame))]
+ [InlineData(9, typeof(Rs50LayoutJFrame))]
+ public void Format_SupportsEveryConfirmedLayout(
+ int layoutValue,
+ Type expectedType)
+ {
+ Rs50OledLayout layout = (Rs50OledLayout)layoutValue;
+ Rs50TelemetryFrameFormatter formatter =
+ new(new Rs50OledConfiguration(layout));
+
+ Rs50OledFrame frame =
+ formatter.Format(ConnectedSnapshot(), DisplayMode.Normal);
+
+ Assert.IsType(expectedType, frame);
+ Rs50OledProtocol.CreateLayout(0x12, frame);
+ }
+
+ [Fact]
+ public void LayoutE_MapsGearSpeedRpmAndSpeedGauge()
+ {
+ TelemetrySnapshot snapshot = ConnectedSnapshot();
+ snapshot.Gear = 3;
+ snapshot.SpeedMetersPerSecond = 100f / 3.6f;
+ snapshot.Rpm = 4000;
+ Rs50TelemetryFrameFormatter formatter =
+ new(new Rs50OledConfiguration(
+ Rs50OledLayout.E,
+ maximumRpm: 8000,
+ gaugeMaximumSpeed: 200));
+
+ Rs50LayoutEFrame frame = Assert.IsType(
+ formatter.Format(snapshot, DisplayMode.Normal));
+
+ Assert.Equal("100 KMH", frame.LeftText);
+ Assert.Equal("3", frame.RightText);
+ Assert.Equal(128, frame.MainGauge.WireValue);
+ Assert.Equal(128, frame.ThinIndicator.WireValue);
+ Rs50OledProtocol.CreateLayout(0x12, frame);
+ }
+
+ [Fact]
+ public void MilesPerHour_UsesMphAndSelectedGaugeUnits()
+ {
+ TelemetrySnapshot snapshot = ConnectedSnapshot();
+ snapshot.SpeedMetersPerSecond = 44.70401f;
+ Rs50TelemetryFrameFormatter formatter =
+ new(new Rs50OledConfiguration(
+ Rs50OledLayout.E,
+ SpeedUnit.MilesPerHour,
+ gaugeMaximumSpeed: 200));
+
+ Rs50LayoutEFrame frame = Assert.IsType(
+ formatter.Format(snapshot, DisplayMode.Normal));
+
+ Assert.Equal("100 MPH", frame.LeftText);
+ Assert.Equal(128, frame.ThinIndicator.WireValue);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ public void EveryLayoutAndMode_ProducesEncodableBoundedFrame(
+ int modeValue)
+ {
+ DisplayMode mode = (DisplayMode)modeValue;
+ TelemetrySnapshot snapshot = ConnectedSnapshot();
+ snapshot.SpeedMetersPerSecond = 1000;
+ snapshot.Rpm = 50000;
+ snapshot.Gear = 99;
+ snapshot.BrakeBiasPercent = 100;
+ snapshot.LastLapTimeSeconds = 5999;
+
+ foreach (Rs50OledLayout layout in Enum.GetValues())
+ {
+ Rs50TelemetryFrameFormatter formatter =
+ new(new Rs50OledConfiguration(layout));
+ Rs50OledFrame frame = formatter.Format(snapshot, mode);
+
+ Rs50OledProtocol.CreateLayout(0x12, frame);
+ }
+ }
+
+ [Fact]
+ public void InvalidTelemetry_UsesSafePlaceholdersAndEmptyGauges()
+ {
+ TelemetrySnapshot snapshot = ConnectedSnapshot();
+ snapshot.SpeedMetersPerSecond = float.NaN;
+ snapshot.Rpm = float.PositiveInfinity;
+ snapshot.Gear = 100;
+ Rs50TelemetryFrameFormatter formatter =
+ new(new Rs50OledConfiguration(Rs50OledLayout.E));
+
+ Rs50LayoutEFrame frame = Assert.IsType(
+ formatter.Format(snapshot, DisplayMode.Normal));
+
+ Assert.Equal("--- KMH", frame.LeftText);
+ Assert.Equal("?", frame.RightText);
+ Assert.Equal(0, frame.MainGauge.WireValue);
+ Assert.Equal(0, frame.ThinIndicator.WireValue);
+ }
+
+ [Fact]
+ public void Configuration_RejectsInvalidGaugeScales()
+ {
+ Assert.Throws(
+ () => new Rs50OledConfiguration((Rs50OledLayout)10));
+ Assert.Throws(
+ () => new Rs50OledConfiguration(
+ Rs50OledLayout.E,
+ (SpeedUnit)10));
+ Assert.Throws(
+ () => new Rs50OledConfiguration(
+ Rs50OledLayout.E,
+ maximumRpm: 0));
+ Assert.Throws(
+ () => new Rs50OledConfiguration(
+ Rs50OledLayout.E,
+ gaugeMaximumSpeed: double.NaN));
+ }
+
+ private static TelemetrySnapshot ConnectedSnapshot() =>
+ new()
+ {
+ ConnectionState = "CONNECTED",
+ Gear = 0,
+ SpeedMetersPerSecond = 0,
+ Rpm = 0,
+ BrakeBiasPercent = 52.3f,
+ LastLapTimeSeconds = 92.481f
+ };
+}
diff --git a/LogiDynamicDash.Tests/SanitizedApplicationRuntimeDiagnosticsTests.cs b/LogiDynamicDash.Tests/SanitizedApplicationRuntimeDiagnosticsTests.cs
new file mode 100644
index 0000000..46a26ae
--- /dev/null
+++ b/LogiDynamicDash.Tests/SanitizedApplicationRuntimeDiagnosticsTests.cs
@@ -0,0 +1,66 @@
+using System.Text.Json;
+using LogiDynamicDash.Diagnostics;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class SanitizedApplicationRuntimeDiagnosticsTests
+{
+ [Fact]
+ public void RecordsRenderAndStopAsSanitizedJsonLines()
+ {
+ StringWriter writer = new();
+ using SanitizedApplicationRuntimeDiagnostics diagnostics =
+ new(writer);
+ TelemetrySnapshot snapshot = new()
+ {
+ ConnectionState = "CONNECTED",
+ IsOnTrack = true,
+ Gear = 0,
+ SpeedMetersPerSecond = 0,
+ SessionIdentity = new IRacingSessionIdentity(
+ IRacingDiscipline.DirtRoad,
+ "DirtRoad",
+ "dirt oval",
+ null)
+ };
+
+ diagnostics.RecordRender(
+ "telemetry",
+ snapshot,
+ DisplayMode.Normal);
+ diagnostics.RecordStop(ApplicationLifecycleState.Stopped);
+
+ string[] lines = writer.ToString().Split(
+ Environment.NewLine,
+ StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(2, lines.Length);
+
+ using JsonDocument render = JsonDocument.Parse(lines[0]);
+ JsonElement root = render.RootElement;
+ Assert.Equal("render", root.GetProperty("event").GetString());
+ Assert.Equal(
+ "telemetry",
+ root.GetProperty("trigger").GetString());
+ Assert.Equal(
+ "CONNECTED",
+ root.GetProperty("connection_state").GetString());
+ Assert.Equal("Normal", root.GetProperty("mode").GetString());
+ Assert.True(root.GetProperty("is_on_track").GetBoolean());
+ Assert.Equal(0, root.GetProperty("gear").GetInt32());
+ Assert.Equal(
+ 0,
+ root.GetProperty("speed_meters_per_second").GetSingle());
+ Assert.True(
+ root.GetProperty("has_session_identity").GetBoolean());
+ Assert.False(root.TryGetProperty("session_identity", out _));
+
+ using JsonDocument stop = JsonDocument.Parse(lines[1]);
+ Assert.Equal(
+ "stop",
+ stop.RootElement.GetProperty("event").GetString());
+ Assert.Equal(
+ "Stopped",
+ stop.RootElement.GetProperty("state").GetString());
+ }
+}
diff --git a/LogiDynamicDash.Tests/SanitizedRs50OledDiagnosticsTests.cs b/LogiDynamicDash.Tests/SanitizedRs50OledDiagnosticsTests.cs
new file mode 100644
index 0000000..1ad328a
--- /dev/null
+++ b/LogiDynamicDash.Tests/SanitizedRs50OledDiagnosticsTests.cs
@@ -0,0 +1,210 @@
+using System.Text.Json;
+using LogiDynamicDash.Diagnostics;
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class SanitizedRs50OledDiagnosticsTests
+{
+ [Fact]
+ public void DiagnosticSession_RecordsOnlySanitizedTypedFields()
+ {
+ ManualTimeProvider clock = new();
+ FakeSession inner = new(clock);
+ StringWriter writer = new();
+ SanitizedRs50OledDiagnostics diagnostics =
+ new(writer, clock);
+ using DiagnosticRs50OledSession session =
+ new(inner, diagnostics, clock);
+
+ session.Open();
+ session.Send(new Rs50LayoutEFrame(
+ Rs50GaugeLevel.FromRatio(0.5),
+ Rs50GaugeLevel.FromRatio(0.25),
+ "100 KMH",
+ "3"));
+
+ string[] lines = writer
+ .ToString()
+ .Split(
+ Environment.NewLine,
+ StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(2, lines.Length);
+
+ using JsonDocument open = JsonDocument.Parse(lines[0]);
+ Assert.Equal(
+ "open",
+ open.RootElement.GetProperty("event").GetString());
+ Assert.Equal(
+ 2000,
+ open.RootElement
+ .GetProperty("elapsed_microseconds")
+ .GetInt64());
+
+ using JsonDocument frame = JsonDocument.Parse(lines[1]);
+ Assert.Equal(
+ "E",
+ frame.RootElement.GetProperty("layout").GetString());
+ Assert.Equal(
+ "acknowledged",
+ frame.RootElement.GetProperty("result").GetString());
+ Assert.Equal(
+ 3000,
+ frame.RootElement
+ .GetProperty("elapsed_microseconds")
+ .GetInt64());
+
+ Assert.DoesNotContain("report", writer.ToString());
+ Assert.DoesNotContain("device", writer.ToString());
+ Assert.DoesNotContain("path", writer.ToString());
+ }
+
+ [Fact]
+ public void Failure_RecordsTypeButNeverExceptionMessage()
+ {
+ ManualTimeProvider clock = new();
+ FakeSession inner = new(clock)
+ {
+ SendException = new IOException(
+ @"secret path \\?\hid#serial-private")
+ };
+ StringWriter writer = new();
+ using DiagnosticRs50OledSession session = new(
+ inner,
+ new SanitizedRs50OledDiagnostics(writer, clock),
+ clock);
+ session.Open();
+
+ Assert.Throws(
+ () => session.Send(new Rs50LayoutAFrame()));
+
+ string text = writer.ToString();
+ Assert.Contains("\"error_type\":\"IOException\"", text);
+ Assert.DoesNotContain("secret", text);
+ Assert.DoesNotContain("serial-private", text);
+ }
+
+ [Fact]
+ public void UnacknowledgedFrame_IsRecordedAsTypedNonFailure()
+ {
+ ManualTimeProvider clock = new();
+ FakeSession inner = new(clock)
+ {
+ Result = Rs50OledSendResult.Unacknowledged
+ };
+ StringWriter writer = new();
+ using DiagnosticRs50OledSession session = new(
+ inner,
+ new SanitizedRs50OledDiagnostics(writer, clock),
+ clock);
+ session.Open();
+
+ Assert.Equal(
+ Rs50OledSendResult.Unacknowledged,
+ session.Send(new Rs50LayoutAFrame()));
+
+ string text = writer.ToString();
+ Assert.Contains("\"result\":\"unacknowledged\"", text);
+ Assert.DoesNotContain("\"event\":\"failure\"", text);
+ }
+
+ [Fact]
+ public void DiagnosticWriteFailure_PropagatesAndOriginalFailureIsNotMasked()
+ {
+ ManualTimeProvider clock = new();
+ DiagnosticRs50OledSession writeFailure = new(
+ new FakeSession(clock),
+ new SanitizedRs50OledDiagnostics(new ThrowingWriter(), clock),
+ clock);
+
+ Assert.Throws(() => writeFailure.Open());
+ Assert.Throws(() => writeFailure.Dispose());
+
+ FakeSession failingInner = new(clock)
+ {
+ SendException = new InvalidOperationException("original")
+ };
+ DiagnosticRs50OledSession operationFailure = new(
+ failingInner,
+ new SanitizedRs50OledDiagnostics(
+ new ThrowAfterWritesWriter(1),
+ clock),
+ clock);
+ operationFailure.Open();
+ InvalidOperationException exception =
+ Assert.Throws(
+ () => operationFailure.Send(new Rs50LayoutAFrame()));
+ Assert.Equal("original", exception.Message);
+ Assert.Throws(() => operationFailure.Dispose());
+ }
+
+ private sealed class FakeSession(ManualTimeProvider clock)
+ : IRs50OledSession
+ {
+ public Exception? SendException { get; set; }
+ public Rs50OledSendResult Result { get; set; } =
+ Rs50OledSendResult.Transmitted;
+
+ public void Open() =>
+ clock.Advance(TimeSpan.FromMilliseconds(2));
+
+ public Rs50OledSendResult Send(Rs50OledFrame frame)
+ {
+ clock.Advance(TimeSpan.FromMilliseconds(3));
+ if (SendException is not null)
+ {
+ throw SendException;
+ }
+
+ return Result;
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+
+ private sealed class ManualTimeProvider : TimeProvider
+ {
+ private long timestamp;
+ private readonly DateTimeOffset origin =
+ new(2026, 7, 29, 0, 0, 0, TimeSpan.Zero);
+
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+
+ public override long GetTimestamp() => timestamp;
+
+ public override DateTimeOffset GetUtcNow() =>
+ origin.AddTicks(timestamp);
+
+ internal void Advance(TimeSpan duration) =>
+ timestamp += duration.Ticks;
+ }
+
+ private sealed class ThrowingWriter : TextWriter
+ {
+ public override System.Text.Encoding Encoding =>
+ System.Text.Encoding.UTF8;
+
+ public override void WriteLine(string? value) =>
+ throw new IOException("injected disk failure");
+ }
+
+ private sealed class ThrowAfterWritesWriter(int writesBeforeFailure)
+ : TextWriter
+ {
+ private int writes;
+
+ public override System.Text.Encoding Encoding =>
+ System.Text.Encoding.UTF8;
+
+ public override void WriteLine(string? value)
+ {
+ if (writes++ >= writesBeforeFailure)
+ {
+ throw new IOException("injected disk failure");
+ }
+ }
+ }
+}
diff --git a/LogiDynamicDash.Tests/SessionProfileResolverTests.cs b/LogiDynamicDash.Tests/SessionProfileResolverTests.cs
new file mode 100644
index 0000000..6ae8b52
--- /dev/null
+++ b/LogiDynamicDash.Tests/SessionProfileResolverTests.cs
@@ -0,0 +1,91 @@
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class SessionProfileResolverTests
+{
+ [Theory]
+ [InlineData("SportsCar", "E")]
+ [InlineData("FormulaCar", "E")]
+ [InlineData("Oval", "D")]
+ [InlineData("DirtOval", "D")]
+ [InlineData("DirtRoad", "E")]
+ public void ExactCarAndCurrentCategory_SelectReviewedProfile(
+ string disciplineName,
+ string expectedLayoutName)
+ {
+ IRacingDiscipline discipline =
+ Enum.Parse(disciplineName);
+ IRacingSessionIdentity identity = Identity(discipline, carId: 42);
+
+ SessionProfileResolution resolution =
+ SessionProfileResolver.Resolve(
+ identity,
+ SpeedUnit.KilometersPerHour);
+
+ Assert.True(resolution.CanApply);
+ Assert.Equal(42, resolution.CarKey?.CarId);
+ Assert.Equal(discipline, resolution.CarKey?.Discipline);
+ Assert.Equal(
+ Enum.Parse(expectedLayoutName),
+ resolution.Recommendation?.Configuration.LayoutFor(
+ DisplayMode.Normal));
+ }
+
+ [Theory]
+ [InlineData("Unknown", 42)]
+ [InlineData("LegacyRoad", 42)]
+ [InlineData("SportsCar", null)]
+ public void AmbiguousIdentity_RequiresManualProfile(
+ string disciplineName,
+ int? carId)
+ {
+ IRacingDiscipline discipline =
+ Enum.Parse(disciplineName);
+
+ SessionProfileResolution resolution =
+ SessionProfileResolver.Resolve(
+ Identity(discipline, carId),
+ SpeedUnit.KilometersPerHour);
+
+ Assert.False(resolution.CanApply);
+ Assert.Null(resolution.CarKey);
+ Assert.Null(resolution.Recommendation);
+ Assert.Contains("manual", resolution.Explanation);
+ }
+
+ [Fact]
+ public void SameCategoryDifferentCar_ProducesDifferentStableKey()
+ {
+ SessionProfileResolution first = SessionProfileResolver.Resolve(
+ Identity(IRacingDiscipline.SportsCar, 100),
+ SpeedUnit.KilometersPerHour);
+ SessionProfileResolution second = SessionProfileResolver.Resolve(
+ Identity(IRacingDiscipline.SportsCar, 200),
+ SpeedUnit.KilometersPerHour);
+
+ Assert.NotEqual(first.CarKey, second.CarKey);
+ Assert.Equal(
+ first.Recommendation?.Configuration.LayoutFor(DisplayMode.Normal),
+ second.Recommendation?.Configuration.LayoutFor(DisplayMode.Normal));
+ Assert.Equal(
+ first.Recommendation?.Configuration.MaximumRpm,
+ second.Recommendation?.Configuration.MaximumRpm);
+ }
+
+ private static IRacingSessionIdentity Identity(
+ IRacingDiscipline discipline,
+ int? carId) =>
+ new(
+ discipline,
+ discipline.ToString(),
+ "road course",
+ new CarIdentity(
+ carId,
+ "cars/example",
+ "Example Car",
+ "Example",
+ 12,
+ "Example Class",
+ false));
+}
diff --git a/LogiDynamicDash.Tests/TelemetryRecorderTests.cs b/LogiDynamicDash.Tests/TelemetryRecorderTests.cs
new file mode 100644
index 0000000..a130e50
--- /dev/null
+++ b/LogiDynamicDash.Tests/TelemetryRecorderTests.cs
@@ -0,0 +1,84 @@
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Offline;
+using LogiDynamicDash.Services;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class TelemetryRecorderTests
+{
+ [Fact]
+ public async Task RecordAsync_WritesStrictReplayAndSamplesAtFiveHertz()
+ {
+ ManualTimeProvider clock = new();
+ ScriptedSource source = new(clock);
+ Rs50TelemetryRecorder recorder = new(source, clock);
+ string directory = Path.Combine(
+ Path.GetTempPath(),
+ $"logidynamicdash-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(directory);
+ string path = Path.Combine(directory, "recording.json");
+
+ try
+ {
+ await recorder.RecordAsync(
+ path,
+ TimeSpan.FromSeconds(1),
+ CancellationToken.None);
+
+ IReadOnlyList events =
+ TelemetryReplayFile.Load(path);
+ Assert.Equal(3, events.Count);
+ Assert.True(events[0].StatusChanged);
+ Assert.Equal(0, events[1].AtMilliseconds);
+ Assert.Equal(200, events[2].AtMilliseconds);
+ }
+ finally
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+
+ if (Directory.Exists(directory))
+ {
+ Directory.Delete(directory);
+ }
+ }
+ }
+
+ private sealed class ScriptedSource(ManualTimeProvider clock)
+ : ITelemetrySource
+ {
+ public Task MonitorAsync(
+ Action onTelemetryUpdated,
+ Action onStatusChanged,
+ CancellationToken cancellationToken)
+ {
+ TelemetrySnapshot snapshot = new()
+ {
+ ConnectionState = "CONNECTED",
+ SpeedMetersPerSecond = 0,
+ Gear = 0
+ };
+ onStatusChanged(snapshot.Copy());
+ onTelemetryUpdated(snapshot.Copy());
+ clock.Advance(TimeSpan.FromMilliseconds(100));
+ snapshot.Gear = 1;
+ onTelemetryUpdated(snapshot.Copy());
+ clock.Advance(TimeSpan.FromMilliseconds(100));
+ snapshot.Gear = 2;
+ onTelemetryUpdated(snapshot.Copy());
+ return Task.CompletedTask;
+ }
+ }
+
+ private sealed class ManualTimeProvider : TimeProvider
+ {
+ private long timestamp;
+
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+ public override long GetTimestamp() => timestamp;
+
+ public void Advance(TimeSpan duration) => timestamp += duration.Ticks;
+ }
+}
diff --git a/LogiDynamicDash.Tests/TelemetryReplayTests.cs b/LogiDynamicDash.Tests/TelemetryReplayTests.cs
new file mode 100644
index 0000000..c91dcdf
--- /dev/null
+++ b/LogiDynamicDash.Tests/TelemetryReplayTests.cs
@@ -0,0 +1,169 @@
+using LogiDynamicDash.Offline;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class TelemetryReplayTests
+{
+ private const string ValidReplay =
+ """
+ {
+ "schemaVersion": 1,
+ "events": [{
+ "atMilliseconds": 0,
+ "statusChanged": true,
+ "connectionState": "CONNECTED",
+ "isOnTrack": false,
+ "gear": 0,
+ "rpm": 900,
+ "speedMetersPerSecond": 0,
+ "brakeBiasPercent": 52.3,
+ "lastLapTimeSeconds": null
+ }]
+ }
+ """;
+
+ [Fact]
+ public void Parse_AcceptsStrictReplay()
+ {
+ TelemetryReplayEvent replayEvent =
+ Assert.Single(TelemetryReplayFile.Parse(ValidReplay));
+
+ Assert.True(replayEvent.StatusChanged);
+ Assert.Equal("CONNECTED", replayEvent.ConnectionState);
+ Assert.Equal(0, replayEvent.Gear);
+ Assert.Null(replayEvent.SessionIdentity);
+ }
+
+ [Fact]
+ public void VersionTwo_RoundTripsSessionAndCarIdentity()
+ {
+ TelemetryReplayEvent expected = new(
+ 0,
+ true,
+ "CONNECTED",
+ true,
+ 3,
+ 7500,
+ 42,
+ 51.5f,
+ 90.2f,
+ new IRacingSessionIdentity(
+ IRacingDiscipline.FormulaCar,
+ "FormulaCar",
+ "road course",
+ new CarIdentity(
+ 123,
+ "formulacar example",
+ "Example Formula",
+ "Formula",
+ 456,
+ "Formula Class",
+ false)));
+ string path = Path.Combine(
+ Path.GetTempPath(),
+ $"logidynamicdash-{Guid.NewGuid():N}.json");
+ try
+ {
+ TelemetryReplayFile.Save(path, [expected]);
+
+ string json = File.ReadAllText(path);
+ TelemetryReplayEvent actual =
+ Assert.Single(TelemetryReplayFile.Parse(json));
+
+ Assert.Contains("\"schemaVersion\": 2", json);
+ Assert.Equal(expected.SessionIdentity, actual.SessionIdentity);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Theory]
+ [InlineData("NotACategory")]
+ [InlineData("formulacar")]
+ public void VersionTwo_RejectsUnknownOrAmbiguousCategory(
+ string discipline)
+ {
+ string json =
+ $$"""
+ {
+ "schemaVersion": 2,
+ "events": [{
+ "atMilliseconds": 0,
+ "statusChanged": true,
+ "connectionState": "CONNECTED",
+ "isOnTrack": false,
+ "gear": 0,
+ "rpm": 900,
+ "speedMetersPerSecond": 0,
+ "brakeBiasPercent": 52.3,
+ "lastLapTimeSeconds": null,
+ "sessionIdentity": {
+ "discipline": "{{discipline}}",
+ "rawCategory": "FormulaCar",
+ "trackType": "road course",
+ "car": null
+ }
+ }]
+ }
+ """;
+
+ Assert.Throws(
+ () => TelemetryReplayFile.Parse(json));
+ }
+
+ [Theory]
+ [InlineData("""{"schemaVersion":3,"events":[]}""")]
+ [InlineData("""{"schemaVersion":1,"events":[]}""")]
+ [InlineData("""{"schemaVersion":1,"events":[{"atMilliseconds":0}]}""")]
+ [InlineData("""{"schemaVersion":1,"events":[{"atMilliseconds":0,"statusChanged":true,"connectionState":"CONNECTED","isOnTrack":false,"gear":0,"rpm":900,"speedMetersPerSecond":0,"brakeBiasPercent":52,"lastLapTimeSeconds":null,"extra":1}]}""")]
+ public void Parse_RejectsUnsupportedEmptyOrAmbiguousReplay(string json)
+ {
+ Assert.ThrowsAny(() => TelemetryReplayFile.Parse(json));
+ }
+
+ [Fact]
+ public void Parse_RejectsBackwardTimeAndNonFiniteNumbers()
+ {
+ const string backward =
+ """
+ {
+ "schemaVersion": 1,
+ "events": [
+ {
+ "atMilliseconds": 1,
+ "statusChanged": true,
+ "connectionState": "CONNECTED",
+ "isOnTrack": false,
+ "gear": 0,
+ "rpm": 900,
+ "speedMetersPerSecond": 0,
+ "brakeBiasPercent": 52.3,
+ "lastLapTimeSeconds": null
+ },
+ {
+ "atMilliseconds": 0,
+ "statusChanged": false,
+ "connectionState": "CONNECTED",
+ "isOnTrack": false,
+ "gear": 0,
+ "rpm": 900,
+ "speedMetersPerSecond": 0,
+ "brakeBiasPercent": 52.3,
+ "lastLapTimeSeconds": null
+ }
+ ]
+ }
+ """;
+
+ Assert.ThrowsAny(() => TelemetryReplayFile.Parse(backward));
+ Assert.ThrowsAny(
+ () => TelemetryReplayFile.Parse(
+ ValidReplay.Replace(
+ "\"rpm\": 900",
+ "\"rpm\": 1e999",
+ StringComparison.Ordinal)));
+ }
+}
diff --git a/LogiDynamicDash.Tests/VirtualEnduranceTests.cs b/LogiDynamicDash.Tests/VirtualEnduranceTests.cs
new file mode 100644
index 0000000..4d64841
--- /dev/null
+++ b/LogiDynamicDash.Tests/VirtualEnduranceTests.cs
@@ -0,0 +1,37 @@
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Tests;
+
+public sealed class VirtualEnduranceTests
+{
+ [Fact]
+ public void Formatter_ProcessesSixVirtualHoursAtTwentyHertz()
+ {
+ const int updates = 6 * 60 * 60 * 20;
+ Rs50TelemetryFrameFormatter formatter = new(
+ new Rs50OledConfiguration(Rs50OledLayout.E));
+ TelemetrySnapshot snapshot = new()
+ {
+ ConnectionState = "CONNECTED",
+ IsOnTrack = true,
+ BrakeBiasPercent = 52.3f,
+ LastLapTimeSeconds = 91.2f
+ };
+ Rs50OledFrame? final = null;
+
+ for (int index = 0; index < updates; index++)
+ {
+ snapshot.Gear = index % 8;
+ snapshot.Rpm = 900 + index % 7500;
+ snapshot.SpeedMetersPerSecond = index % 90;
+ DisplayMode mode = index % 50_000 == 0
+ ? DisplayMode.ConnectionProblem
+ : DisplayMode.Normal;
+ final = formatter.Format(snapshot, mode);
+ }
+
+ Assert.NotNull(final);
+ Assert.IsType(final);
+ }
+}
diff --git a/LogiDynamicDash.slnx b/LogiDynamicDash.slnx
index 9f56e99..f6d5234 100644
--- a/LogiDynamicDash.slnx
+++ b/LogiDynamicDash.slnx
@@ -1,3 +1,5 @@
+
+
diff --git a/LogiDynamicDash/Configuration/ApplicationDisplayFactory.cs b/LogiDynamicDash/Configuration/ApplicationDisplayFactory.cs
new file mode 100644
index 0000000..7508e8f
--- /dev/null
+++ b/LogiDynamicDash/Configuration/ApplicationDisplayFactory.cs
@@ -0,0 +1,107 @@
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Diagnostics;
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Configuration;
+
+internal sealed record ApplicationDisplaySelection(
+ IApplicationDisplay Display,
+ bool UsesPhysicalHardware,
+ TimeSpan? HardwareTrialDuration)
+{
+ internal bool IsBoundedHardwareTrial =>
+ UsesPhysicalHardware && HardwareTrialDuration is not null;
+}
+
+internal static class ApplicationDisplayFactory
+{
+ internal static bool TryCreate(
+ string[] arguments,
+ out ApplicationDisplaySelection? selection) =>
+ TryCreate(
+ arguments,
+ Rs50OledSessionFactory.OpenPhysicalWithLocalDiagnostics,
+ out selection);
+
+ internal static bool TryCreate(
+ string[] arguments,
+ Func sessionFactory,
+ out ApplicationDisplaySelection? selection) =>
+ TryCreate(
+ arguments,
+ sessionFactory,
+ () => new ConsoleDashboard(),
+ Rs50OledConfigurationFile.Load,
+ out selection);
+
+ internal static bool TryCreate(
+ string[] arguments,
+ Func sessionFactory,
+ Func consoleFactory,
+ Func configurationLoader,
+ out ApplicationDisplaySelection? selection)
+ {
+ ArgumentNullException.ThrowIfNull(arguments);
+ ArgumentNullException.ThrowIfNull(sessionFactory);
+ ArgumentNullException.ThrowIfNull(consoleFactory);
+ ArgumentNullException.ThrowIfNull(configurationLoader);
+
+ if (arguments.Length == 0)
+ {
+ selection = new(
+ consoleFactory(),
+ UsesPhysicalHardware: false,
+ HardwareTrialDuration: null);
+ return true;
+ }
+
+ string configurationPath;
+ TimeSpan? duration;
+ float? maximumSpeedMetersPerSecond;
+ if (Rs50StationaryTrialOptions.TryParse(
+ arguments,
+ out Rs50StationaryTrialOptions? stationary))
+ {
+ configurationPath = stationary!.ConfigurationPath;
+ duration = Rs50StationaryTrialOptions.Duration;
+ maximumSpeedMetersPerSecond =
+ Rs50OledDisplaySink.MaximumStationarySpeedMetersPerSecond;
+ }
+ else if (Rs50LowSpeedTrialOptions.TryParse(
+ arguments,
+ out Rs50LowSpeedTrialOptions? lowSpeed))
+ {
+ configurationPath = lowSpeed!.ConfigurationPath;
+ duration = Rs50LowSpeedTrialOptions.Duration;
+ maximumSpeedMetersPerSecond =
+ Rs50LowSpeedTrialOptions.MaximumSpeedMetersPerSecond;
+ }
+ else if (Rs50DrivingTrialOptions.TryParse(
+ arguments,
+ out Rs50DrivingTrialOptions? driving))
+ {
+ configurationPath = driving!.ConfigurationPath;
+ duration = null;
+ maximumSpeedMetersPerSecond = null;
+ }
+ else
+ {
+ selection = null;
+ return false;
+ }
+
+ Rs50TelemetryFrameFormatter formatter = new(
+ configurationLoader(configurationPath));
+ selection = new(
+ new CompositeApplicationDisplay(
+ consoleFactory(),
+ new Rs50OledDisplaySink(
+ sessionFactory,
+ formatter,
+ maximumSpeedMetersPerSecond)),
+ UsesPhysicalHardware: true,
+ HardwareTrialDuration: duration);
+ return true;
+ }
+}
diff --git a/LogiDynamicDash/Configuration/Rs50DrivingTrialOptions.cs b/LogiDynamicDash/Configuration/Rs50DrivingTrialOptions.cs
new file mode 100644
index 0000000..8d7d5d0
--- /dev/null
+++ b/LogiDynamicDash/Configuration/Rs50DrivingTrialOptions.cs
@@ -0,0 +1,45 @@
+namespace LogiDynamicDash.Configuration;
+
+internal sealed record Rs50DrivingTrialOptions(
+ string ConfigurationPath)
+{
+ private const int ArgumentCount = 10;
+
+ internal static bool TryParse(
+ string[] arguments,
+ out Rs50DrivingTrialOptions? options)
+ {
+ ArgumentNullException.ThrowIfNull(arguments);
+ options = null;
+ if (arguments.Length != ArgumentCount ||
+ arguments[0] != "--enable-rs50-oled-driving-trial" ||
+ arguments[1] != "--confirm-ghub-closed" ||
+ arguments[2] != "--confirm-iracing-running" ||
+ arguments[3] != "--confirm-controlled-driving-session" ||
+ arguments[4] != "--confirm-rs50-dynamic-selected" ||
+ arguments[5] != "--acknowledge-no-speed-limit" ||
+ arguments[6] != "--acknowledge-manual-stop-required" ||
+ arguments[7] != "--config" ||
+ string.IsNullOrWhiteSpace(arguments[8]) ||
+ arguments[9] != "--confirm-settings")
+ {
+ return false;
+ }
+
+ options = new Rs50DrivingTrialOptions(arguments[8]);
+ return true;
+ }
+
+ internal static string Usage =>
+ "Manually stopped, no-speed-limit RS50 OLED driving trial:\n" +
+ " LogiDynamicDash.exe " +
+ "--enable-rs50-oled-driving-trial " +
+ "--confirm-ghub-closed " +
+ "--confirm-iracing-running " +
+ "--confirm-controlled-driving-session " +
+ "--confirm-rs50-dynamic-selected " +
+ "--acknowledge-no-speed-limit " +
+ "--acknowledge-manual-stop-required " +
+ "--config " +
+ "--confirm-settings";
+}
diff --git a/LogiDynamicDash/Configuration/Rs50LowSpeedTrialOptions.cs b/LogiDynamicDash/Configuration/Rs50LowSpeedTrialOptions.cs
new file mode 100644
index 0000000..0796556
--- /dev/null
+++ b/LogiDynamicDash/Configuration/Rs50LowSpeedTrialOptions.cs
@@ -0,0 +1,51 @@
+namespace LogiDynamicDash.Configuration;
+
+internal sealed record Rs50LowSpeedTrialOptions(
+ string ConfigurationPath)
+{
+ internal static readonly TimeSpan Duration = TimeSpan.FromSeconds(15);
+
+ internal const float MaximumSpeedMetersPerSecond = 20f / 3.6f;
+
+ private const int ArgumentCount = 11;
+
+ internal static bool TryParse(
+ string[] arguments,
+ out Rs50LowSpeedTrialOptions? options)
+ {
+ ArgumentNullException.ThrowIfNull(arguments);
+ options = null;
+ if (arguments.Length != ArgumentCount ||
+ arguments[0] != "--enable-rs50-oled-low-speed-trial" ||
+ arguments[1] != "--confirm-ghub-closed" ||
+ arguments[2] != "--confirm-iracing-running" ||
+ arguments[3] != "--confirm-controlled-pit-lane" ||
+ arguments[4] != "--confirm-rs50-dynamic-selected" ||
+ arguments[5] != "--confirm-15-second-limit" ||
+ arguments[6] != "--confirm-maximum-20-kmh" ||
+ arguments[7] != "--acknowledge-stop-on-speed-limit" ||
+ arguments[8] != "--config" ||
+ string.IsNullOrWhiteSpace(arguments[9]) ||
+ arguments[10] != "--confirm-settings")
+ {
+ return false;
+ }
+
+ options = new Rs50LowSpeedTrialOptions(arguments[9]);
+ return true;
+ }
+
+ internal static string Usage =>
+ "Bounded low-speed RS50 OLED validation only:\n" +
+ " LogiDynamicDash.exe " +
+ "--enable-rs50-oled-low-speed-trial " +
+ "--confirm-ghub-closed " +
+ "--confirm-iracing-running " +
+ "--confirm-controlled-pit-lane " +
+ "--confirm-rs50-dynamic-selected " +
+ "--confirm-15-second-limit " +
+ "--confirm-maximum-20-kmh " +
+ "--acknowledge-stop-on-speed-limit " +
+ "--config " +
+ "--confirm-settings";
+}
diff --git a/LogiDynamicDash/Configuration/Rs50OledConfigurationFile.cs b/LogiDynamicDash/Configuration/Rs50OledConfigurationFile.cs
new file mode 100644
index 0000000..de1c524
--- /dev/null
+++ b/LogiDynamicDash/Configuration/Rs50OledConfigurationFile.cs
@@ -0,0 +1,259 @@
+using System.Text.Json;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Configuration;
+
+internal static class Rs50OledConfigurationFile
+{
+ private const int MaximumFileBytes = 16 * 1024;
+
+ private static readonly string[] CommonProperties =
+ [
+ "schemaVersion",
+ "speedUnit",
+ "maximumRpm",
+ "gaugeMaximumSpeed"
+ ];
+
+ internal static Rs50OledConfiguration Load(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ FileInfo file = new(path);
+ if (!file.Exists)
+ {
+ throw new FileNotFoundException(
+ "The OLED configuration file was not found.",
+ path);
+ }
+
+ if (file.Length > MaximumFileBytes)
+ {
+ throw new InvalidDataException(
+ $"The OLED configuration exceeds {MaximumFileBytes} bytes.");
+ }
+
+ return Parse(File.ReadAllText(file.FullName));
+ }
+
+ internal static Rs50OledConfiguration Parse(string json)
+ {
+ ArgumentNullException.ThrowIfNull(json);
+ if (json.Length > MaximumFileBytes)
+ {
+ throw new InvalidDataException(
+ $"The OLED configuration exceeds {MaximumFileBytes} bytes.");
+ }
+
+ using JsonDocument document = JsonDocument.Parse(
+ json,
+ new JsonDocumentOptions
+ {
+ AllowTrailingCommas = false,
+ CommentHandling = JsonCommentHandling.Disallow,
+ MaxDepth = 4
+ });
+
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ throw new InvalidDataException(
+ "The OLED configuration root must be an object.");
+ }
+
+ Dictionary properties = ReadUniqueProperties(
+ document.RootElement);
+ if (!properties.TryGetValue("schemaVersion", out JsonElement version) ||
+ !version.TryGetInt32(out int schemaVersion) ||
+ schemaVersion is not (1 or 2))
+ {
+ throw new InvalidDataException(
+ "Unsupported OLED configuration schema version.");
+ }
+
+ string layoutProperty = schemaVersion == 1 ? "layout" : "layouts";
+ string[] allowedProperties = [.. CommonProperties, layoutProperty];
+ string? unknownProperty = properties.Keys.FirstOrDefault(
+ name => !allowedProperties.Contains(name, StringComparer.Ordinal));
+ if (unknownProperty is not null)
+ {
+ throw new InvalidDataException(
+ $"Unknown OLED configuration property '{unknownProperty}'.");
+ }
+
+ if (properties.Count != allowedProperties.Length ||
+ allowedProperties.Any(name => !properties.ContainsKey(name)))
+ {
+ throw new InvalidDataException(
+ "The OLED configuration must contain every required property.");
+ }
+
+ IReadOnlyDictionary layouts =
+ schemaVersion == 1
+ ? AllModes(ParseLayout(properties["layout"], "layout"))
+ : ParseLayouts(properties["layouts"]);
+
+ string speedUnitText =
+ RequireString(properties["speedUnit"], "speedUnit");
+ SpeedUnit speedUnit = speedUnitText switch
+ {
+ "KMH" => SpeedUnit.KilometersPerHour,
+ "MPH" => SpeedUnit.MilesPerHour,
+ _ => throw new InvalidDataException(
+ "Speed unit must be KMH or MPH.")
+ };
+
+ double maximumRpm =
+ RequireFiniteNumber(properties["maximumRpm"], "maximumRpm");
+ double gaugeMaximumSpeed = RequireFiniteNumber(
+ properties["gaugeMaximumSpeed"],
+ "gaugeMaximumSpeed");
+
+ if (maximumRpm is < 1000 or > 30000)
+ {
+ throw new InvalidDataException(
+ "Maximum RPM must be between 1000 and 30000.");
+ }
+
+ if (gaugeMaximumSpeed is < 10 or > 500)
+ {
+ throw new InvalidDataException(
+ "Gauge maximum speed must be between 10 and 500.");
+ }
+
+ return new Rs50OledConfiguration(
+ layouts,
+ speedUnit,
+ maximumRpm,
+ gaugeMaximumSpeed);
+ }
+
+ internal static string Serialize(Rs50OledConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ string Unit() => configuration.SpeedUnit switch
+ {
+ SpeedUnit.KilometersPerHour => "KMH",
+ SpeedUnit.MilesPerHour => "MPH",
+ _ => throw new ArgumentOutOfRangeException(nameof(configuration))
+ };
+
+ string json = JsonSerializer.Serialize(
+ new
+ {
+ schemaVersion = 2,
+ layouts = new
+ {
+ normal = configuration.LayoutFor(DisplayMode.Normal)
+ .ToString(),
+ brakeBias = configuration.LayoutFor(DisplayMode.BrakeBias)
+ .ToString(),
+ lastLap = configuration.LayoutFor(DisplayMode.LastLap)
+ .ToString(),
+ connectionProblem = configuration
+ .LayoutFor(DisplayMode.ConnectionProblem)
+ .ToString()
+ },
+ speedUnit = Unit(),
+ maximumRpm = configuration.MaximumRpm,
+ gaugeMaximumSpeed = configuration.GaugeMaximumSpeed
+ },
+ new JsonSerializerOptions { WriteIndented = true });
+ _ = Parse(json);
+ return json + Environment.NewLine;
+ }
+
+ private static Dictionary ReadUniqueProperties(
+ JsonElement element)
+ {
+ Dictionary properties = new(StringComparer.Ordinal);
+ foreach (JsonProperty property in
+ element.EnumerateObject())
+ {
+ if (!properties.TryAdd(property.Name, property.Value))
+ {
+ throw new InvalidDataException(
+ $"Duplicate OLED configuration property '{property.Name}'.");
+ }
+ }
+
+ return properties;
+ }
+
+ private static Rs50OledLayout ParseLayout(
+ JsonElement element,
+ string propertyName)
+ {
+ string layoutText = RequireString(element, propertyName);
+ if (layoutText.Length != 1 ||
+ layoutText[0] is < 'A' or > 'J')
+ {
+ throw new InvalidDataException(
+ "Layout must be one uppercase letter from A through J.");
+ }
+
+ return (Rs50OledLayout)(layoutText[0] - 'A');
+ }
+
+ private static IReadOnlyDictionary ParseLayouts(
+ JsonElement element)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ throw new InvalidDataException(
+ "OLED configuration property 'layouts' must be an object.");
+ }
+
+ Dictionary values = ReadUniqueProperties(element);
+ Dictionary names = new(StringComparer.Ordinal)
+ {
+ ["normal"] = DisplayMode.Normal,
+ ["brakeBias"] = DisplayMode.BrakeBias,
+ ["lastLap"] = DisplayMode.LastLap,
+ ["connectionProblem"] = DisplayMode.ConnectionProblem
+ };
+ if (values.Count != names.Count ||
+ values.Keys.Any(name => !names.ContainsKey(name)) ||
+ names.Keys.Any(name => !values.ContainsKey(name)))
+ {
+ throw new InvalidDataException(
+ "Layouts must contain exactly normal, brakeBias, lastLap, " +
+ "and connectionProblem.");
+ }
+
+ return values.ToDictionary(
+ pair => names[pair.Key],
+ pair => ParseLayout(pair.Value, $"layouts.{pair.Key}"));
+ }
+
+ private static IReadOnlyDictionary AllModes(
+ Rs50OledLayout layout) =>
+ Enum.GetValues().ToDictionary(mode => mode, _ => layout);
+
+ private static string RequireString(
+ JsonElement element,
+ string propertyName)
+ {
+ if (element.ValueKind != JsonValueKind.String)
+ {
+ throw new InvalidDataException(
+ $"OLED configuration property '{propertyName}' must be text.");
+ }
+
+ return element.GetString()!;
+ }
+
+ private static double RequireFiniteNumber(
+ JsonElement element,
+ string propertyName)
+ {
+ if (element.ValueKind != JsonValueKind.Number ||
+ !element.TryGetDouble(out double value) ||
+ !double.IsFinite(value))
+ {
+ throw new InvalidDataException(
+ $"OLED configuration property '{propertyName}' must be a " +
+ "finite number.");
+ }
+
+ return value;
+ }
+}
diff --git a/LogiDynamicDash/Configuration/Rs50ProductionRunOptions.cs b/LogiDynamicDash/Configuration/Rs50ProductionRunOptions.cs
new file mode 100644
index 0000000..8fb0475
--- /dev/null
+++ b/LogiDynamicDash/Configuration/Rs50ProductionRunOptions.cs
@@ -0,0 +1,88 @@
+namespace LogiDynamicDash.Configuration;
+
+internal sealed record Rs50ProductionRunOptions(
+ string ConfigurationPath,
+ string ProfileDirectory,
+ bool AutomaticProfiles,
+ double LastLapDisplaySeconds)
+{
+ internal const string Usage =
+ "Production: --run-rs50-oled --config " +
+ "[--profiles ] [--manual-profile] " +
+ "[--last-lap-seconds <1-15>]";
+
+ internal static bool TryParse(
+ string[] arguments,
+ out Rs50ProductionRunOptions? options)
+ {
+ options = null;
+ if (arguments.Length < 3 ||
+ !string.Equals(
+ arguments[0],
+ "--run-rs50-oled",
+ StringComparison.Ordinal) ||
+ !string.Equals(arguments[1], "--config", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ string configurationPath = arguments[2];
+ string profileDirectory = Path.Combine(
+ Environment.GetFolderPath(
+ Environment.SpecialFolder.LocalApplicationData),
+ "LogiDynamicDash",
+ "profiles");
+ bool automatic = true;
+ double lastLapSeconds = 5;
+
+ for (int index = 3; index < arguments.Length;)
+ {
+ if (arguments[index] == "--manual-profile")
+ {
+ automatic = false;
+ index++;
+ continue;
+ }
+
+ if (index + 1 >= arguments.Length)
+ {
+ return false;
+ }
+
+ if (arguments[index] == "--profiles")
+ {
+ profileDirectory = arguments[index + 1];
+ }
+ else if (arguments[index] == "--last-lap-seconds" &&
+ double.TryParse(
+ arguments[index + 1],
+ System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out double parsed))
+ {
+ lastLapSeconds = parsed;
+ }
+ else
+ {
+ return false;
+ }
+
+ index += 2;
+ }
+
+ if (string.IsNullOrWhiteSpace(configurationPath) ||
+ string.IsNullOrWhiteSpace(profileDirectory) ||
+ !double.IsFinite(lastLapSeconds) ||
+ lastLapSeconds is < 1 or > 15)
+ {
+ return false;
+ }
+
+ options = new(
+ configurationPath,
+ profileDirectory,
+ automatic,
+ lastLapSeconds);
+ return true;
+ }
+}
diff --git a/LogiDynamicDash/Configuration/Rs50ProfileStore.cs b/LogiDynamicDash/Configuration/Rs50ProfileStore.cs
new file mode 100644
index 0000000..6063969
--- /dev/null
+++ b/LogiDynamicDash/Configuration/Rs50ProfileStore.cs
@@ -0,0 +1,92 @@
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Configuration;
+
+internal sealed class Rs50ProfileStore(string directoryPath)
+{
+ internal string DirectoryPath { get; } =
+ Path.GetFullPath(
+ string.IsNullOrWhiteSpace(directoryPath)
+ ? throw new ArgumentException(
+ "A profile directory is required.",
+ nameof(directoryPath))
+ : directoryPath);
+
+ internal Rs50OledConfiguration? Resolve(IRacingSessionIdentity identity)
+ {
+ ArgumentNullException.ThrowIfNull(identity);
+
+ if (identity.Car?.CarId is int carId)
+ {
+ string carPath = Path.Combine(
+ DirectoryPath,
+ $"car-{carId}.json");
+ if (File.Exists(carPath))
+ {
+ return Rs50OledConfigurationFile.Load(carPath);
+ }
+ }
+
+ string? disciplineName = FileName(identity.Discipline);
+ if (disciplineName is null)
+ {
+ return null;
+ }
+
+ string disciplinePath = Path.Combine(
+ DirectoryPath,
+ $"discipline-{disciplineName}.json");
+ return File.Exists(disciplinePath)
+ ? Rs50OledConfigurationFile.Load(disciplinePath)
+ : null;
+ }
+
+ internal string SaveForDiscipline(
+ IRacingDiscipline discipline,
+ Rs50OledConfiguration configuration)
+ {
+ string disciplineName = FileName(discipline) ??
+ throw new ArgumentOutOfRangeException(
+ nameof(discipline),
+ "A supported discipline is required.");
+ return Save(
+ $"discipline-{disciplineName}.json",
+ configuration);
+ }
+
+ internal string SaveForCar(
+ int carId,
+ Rs50OledConfiguration configuration)
+ {
+ if (carId <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(carId));
+ }
+
+ return Save($"car-{carId}.json", configuration);
+ }
+
+ private string Save(
+ string fileName,
+ Rs50OledConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ Directory.CreateDirectory(DirectoryPath);
+ string path = Path.Combine(DirectoryPath, fileName);
+ File.WriteAllText(
+ path,
+ Rs50OledConfigurationFile.Serialize(configuration));
+ return path;
+ }
+
+ private static string? FileName(IRacingDiscipline discipline) =>
+ discipline switch
+ {
+ IRacingDiscipline.SportsCar => "sports-car",
+ IRacingDiscipline.FormulaCar => "formula-car",
+ IRacingDiscipline.Oval => "oval",
+ IRacingDiscipline.DirtOval => "dirt-oval",
+ IRacingDiscipline.DirtRoad => "dirt-road",
+ _ => null
+ };
+}
diff --git a/LogiDynamicDash/Configuration/Rs50StationaryTrialOptions.cs b/LogiDynamicDash/Configuration/Rs50StationaryTrialOptions.cs
new file mode 100644
index 0000000..d32e128
--- /dev/null
+++ b/LogiDynamicDash/Configuration/Rs50StationaryTrialOptions.cs
@@ -0,0 +1,49 @@
+namespace LogiDynamicDash.Configuration;
+
+internal sealed record Rs50StationaryTrialOptions(
+ string ConfigurationPath)
+{
+ internal static readonly TimeSpan Duration = TimeSpan.FromSeconds(10);
+
+ private const int ArgumentCount = 10;
+
+ internal static bool TryParse(
+ string[] arguments,
+ out Rs50StationaryTrialOptions? options)
+ {
+ ArgumentNullException.ThrowIfNull(arguments);
+ options = null;
+
+ if (arguments.Length != ArgumentCount ||
+ arguments[0] != "--enable-rs50-oled-stationary-trial" ||
+ arguments[1] != "--confirm-ghub-closed" ||
+ arguments[2] != "--confirm-iracing-running" ||
+ arguments[3] != "--confirm-car-stationary-in-pits" ||
+ arguments[4] != "--confirm-rs50-dynamic-selected" ||
+ arguments[5] != "--confirm-10-second-limit" ||
+ arguments[6] != "--acknowledge-no-moving-car-use" ||
+ arguments[7] != "--config" ||
+ string.IsNullOrWhiteSpace(arguments[8]) ||
+ arguments[9] != "--confirm-settings")
+ {
+ return false;
+ }
+
+ options = new Rs50StationaryTrialOptions(arguments[8]);
+ return true;
+ }
+
+ internal static string Usage =>
+ " LogiDynamicDash.exe\n\n" +
+ "Bounded stationary RS50 OLED validation only:\n" +
+ " LogiDynamicDash.exe " +
+ "--enable-rs50-oled-stationary-trial " +
+ "--confirm-ghub-closed " +
+ "--confirm-iracing-running " +
+ "--confirm-car-stationary-in-pits " +
+ "--confirm-rs50-dynamic-selected " +
+ "--confirm-10-second-limit " +
+ "--acknowledge-no-moving-car-use " +
+ "--config " +
+ "--confirm-settings";
+}
diff --git a/LogiDynamicDash/Controllers/DisplayController.cs b/LogiDynamicDash/Controllers/DisplayController.cs
index 58f165c..1db81d9 100644
--- a/LogiDynamicDash/Controllers/DisplayController.cs
+++ b/LogiDynamicDash/Controllers/DisplayController.cs
@@ -2,29 +2,33 @@
namespace LogiDynamicDash.Controllers;
-internal sealed class DisplayController
+internal sealed class DisplayController(
+ TimeProvider? timeProvider = null,
+ TimeSpan? lastLapDuration = null)
{
+ private readonly TimeProvider clock = timeProvider ?? TimeProvider.System;
+
private static readonly TimeSpan BrakeBiasDuration =
TimeSpan.FromSeconds(2);
- private static readonly TimeSpan LastLapDuration =
- TimeSpan.FromSeconds(3);
+ private readonly TimeSpan lastLapDisplayDuration =
+ ValidateLastLapDuration(lastLapDuration ?? TimeSpan.FromSeconds(5));
private float? _previousBrakeBiasPercent;
private float? _previousLastLapTimeSeconds;
private bool _lastLapInitialized;
- private DateTime _brakeBiasExpiresAt =
- DateTime.MinValue;
+ private DateTimeOffset _brakeBiasExpiresAt =
+ DateTimeOffset.MinValue;
- private DateTime _lastLapExpiresAt =
- DateTime.MinValue;
+ private DateTimeOffset _lastLapExpiresAt =
+ DateTimeOffset.MinValue;
public DisplayMode SelectMode(
TelemetrySnapshot snapshot)
{
- DateTime now = DateTime.UtcNow;
+ DateTimeOffset now = clock.GetUtcNow();
DetectCompletedLap(snapshot, now);
DetectBrakeBiasChange(snapshot, now);
@@ -49,7 +53,7 @@ public DisplayMode SelectMode(
private void DetectBrakeBiasChange(
TelemetrySnapshot snapshot,
- DateTime now)
+ DateTimeOffset now)
{
if (snapshot.BrakeBiasPercent is not float currentBrakeBias)
{
@@ -81,7 +85,7 @@ private void DetectBrakeBiasChange(
private void DetectCompletedLap(
TelemetrySnapshot snapshot,
- DateTime now)
+ DateTimeOffset now)
{
if (!_lastLapInitialized)
{
@@ -99,24 +103,27 @@ private void DetectCompletedLap(
return;
}
- if (_previousLastLapTimeSeconds is float previousLastLap)
+ if (_previousLastLapTimeSeconds is not float previousLastLap)
{
- float difference =
- MathF.Abs(
- currentLastLap -
- previousLastLap);
-
- if (difference < 0.001f)
- {
- return;
- }
+ _previousLastLapTimeSeconds = currentLastLap;
+ return;
+ }
+
+ float difference =
+ MathF.Abs(
+ currentLastLap -
+ previousLastLap);
+
+ if (difference < 0.001f)
+ {
+ return;
}
_previousLastLapTimeSeconds =
currentLastLap;
_lastLapExpiresAt =
- now.Add(LastLapDuration);
+ now.Add(lastLapDisplayDuration);
}
private static bool IsConnected(
@@ -127,4 +134,16 @@ private static bool IsConnected(
"CONNECTED",
StringComparison.OrdinalIgnoreCase);
}
-}
\ No newline at end of file
+
+ private static TimeSpan ValidateLastLapDuration(TimeSpan duration)
+ {
+ if (duration < TimeSpan.FromSeconds(1) ||
+ duration > TimeSpan.FromSeconds(15))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(lastLapDuration));
+ }
+
+ return duration;
+ }
+}
diff --git a/LogiDynamicDash/Diagnostics/DiagnosticRs50OledSession.cs b/LogiDynamicDash/Diagnostics/DiagnosticRs50OledSession.cs
new file mode 100644
index 0000000..c0deb8a
--- /dev/null
+++ b/LogiDynamicDash/Diagnostics/DiagnosticRs50OledSession.cs
@@ -0,0 +1,85 @@
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Diagnostics;
+
+internal sealed class DiagnosticRs50OledSession(
+ IRs50OledSession inner,
+ IRs50OledDiagnostics diagnostics,
+ TimeProvider? timeProvider = null) : IRs50OledSession
+{
+ private readonly TimeProvider clock =
+ timeProvider ?? TimeProvider.System;
+ private bool disposed;
+
+ public void Open()
+ {
+ long started = clock.GetTimestamp();
+ try
+ {
+ inner.Open();
+ diagnostics.RecordOpen(ElapsedMicroseconds(started));
+ }
+ catch (Exception exception)
+ {
+ RecordFailureWithoutMasking("open", exception);
+ throw;
+ }
+ }
+
+ public Rs50OledSendResult Send(Rs50OledFrame frame)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ long started = clock.GetTimestamp();
+ try
+ {
+ Rs50OledSendResult result = inner.Send(frame);
+ diagnostics.RecordFrame(
+ frame.Layout,
+ result,
+ ElapsedMicroseconds(started));
+ return result;
+ }
+ catch (Exception exception)
+ {
+ RecordFailureWithoutMasking("send", exception);
+ throw;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+ try
+ {
+ inner.Dispose();
+ diagnostics.RecordClose();
+ }
+ finally
+ {
+ diagnostics.Dispose();
+ }
+ }
+
+ private long ElapsedMicroseconds(long started) =>
+ (long)(clock.GetElapsedTime(started).TotalMilliseconds * 1000);
+
+ private void RecordFailureWithoutMasking(
+ string operation,
+ Exception exception)
+ {
+ try
+ {
+ diagnostics.RecordFailure(operation, exception.GetType());
+ }
+ catch
+ {
+ // Diagnostic failure must not replace the operational exception.
+ }
+ }
+}
diff --git a/LogiDynamicDash/Diagnostics/IApplicationRuntimeDiagnostics.cs b/LogiDynamicDash/Diagnostics/IApplicationRuntimeDiagnostics.cs
new file mode 100644
index 0000000..43da7ea
--- /dev/null
+++ b/LogiDynamicDash/Diagnostics/IApplicationRuntimeDiagnostics.cs
@@ -0,0 +1,13 @@
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Diagnostics;
+
+internal interface IApplicationRuntimeDiagnostics : IDisposable
+{
+ void RecordRender(
+ string trigger,
+ TelemetrySnapshot snapshot,
+ DisplayMode mode);
+
+ void RecordStop(ApplicationLifecycleState state);
+}
diff --git a/LogiDynamicDash/Diagnostics/IRs50OledDiagnostics.cs b/LogiDynamicDash/Diagnostics/IRs50OledDiagnostics.cs
new file mode 100644
index 0000000..c1037fe
--- /dev/null
+++ b/LogiDynamicDash/Diagnostics/IRs50OledDiagnostics.cs
@@ -0,0 +1,18 @@
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Diagnostics;
+
+internal interface IRs50OledDiagnostics : IDisposable
+{
+ void RecordOpen(long elapsedMicroseconds);
+
+ void RecordFrame(
+ Rs50OledLayout layout,
+ Rs50OledSendResult result,
+ long elapsedMicroseconds);
+
+ void RecordFailure(string operation, Type exceptionType);
+
+ void RecordClose();
+}
diff --git a/LogiDynamicDash/Diagnostics/Rs50OledSessionFactory.cs b/LogiDynamicDash/Diagnostics/Rs50OledSessionFactory.cs
new file mode 100644
index 0000000..f6c66bb
--- /dev/null
+++ b/LogiDynamicDash/Diagnostics/Rs50OledSessionFactory.cs
@@ -0,0 +1,27 @@
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Hidpp.Transport;
+
+namespace LogiDynamicDash.Diagnostics;
+
+internal static class Rs50OledSessionFactory
+{
+ internal static IRs50OledSession OpenPhysicalWithLocalDiagnostics()
+ {
+ IRs50OledExchange? exchange = null;
+ IRs50OledDiagnostics? diagnostics = null;
+ try
+ {
+ exchange = Rs50OledDeviceExchange.Open();
+ diagnostics = SanitizedRs50OledDiagnostics.CreateLocal();
+ return new DiagnosticRs50OledSession(
+ new Rs50OledSession(exchange),
+ diagnostics);
+ }
+ catch
+ {
+ diagnostics?.Dispose();
+ exchange?.Dispose();
+ throw;
+ }
+ }
+}
diff --git a/LogiDynamicDash/Diagnostics/SanitizedApplicationRuntimeDiagnostics.cs b/LogiDynamicDash/Diagnostics/SanitizedApplicationRuntimeDiagnostics.cs
new file mode 100644
index 0000000..e92ef83
--- /dev/null
+++ b/LogiDynamicDash/Diagnostics/SanitizedApplicationRuntimeDiagnostics.cs
@@ -0,0 +1,99 @@
+using System.Text.Json;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Diagnostics;
+
+internal sealed class SanitizedApplicationRuntimeDiagnostics(
+ TextWriter writer,
+ TimeProvider? timeProvider = null,
+ bool ownsWriter = false) : IApplicationRuntimeDiagnostics
+{
+ private readonly TimeProvider clock =
+ timeProvider ?? TimeProvider.System;
+ private readonly object synchronization = new();
+ private bool disposed;
+
+ internal static SanitizedApplicationRuntimeDiagnostics CreateLocal()
+ {
+ string directory = Path.Combine(
+ Environment.GetFolderPath(
+ Environment.SpecialFolder.LocalApplicationData),
+ "LogiDynamicDash",
+ "logs");
+ Directory.CreateDirectory(directory);
+ string timestamp =
+ DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss-fffffff");
+ string path = Path.Combine(
+ directory,
+ $"application-{timestamp}.jsonl");
+ StreamWriter writer = new(path, append: false)
+ {
+ AutoFlush = true
+ };
+ return new SanitizedApplicationRuntimeDiagnostics(
+ writer,
+ ownsWriter: true);
+ }
+
+ public void RecordRender(
+ string trigger,
+ TelemetrySnapshot snapshot,
+ DisplayMode mode)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(trigger);
+ ArgumentNullException.ThrowIfNull(snapshot);
+ Write(
+ "render",
+ new Dictionary
+ {
+ ["trigger"] = trigger,
+ ["connection_state"] = snapshot.ConnectionState,
+ ["mode"] = mode.ToString(),
+ ["is_on_track"] = snapshot.IsOnTrack,
+ ["gear"] = snapshot.Gear,
+ ["speed_meters_per_second"] =
+ snapshot.SpeedMetersPerSecond,
+ ["has_session_identity"] =
+ snapshot.SessionIdentity is not null
+ });
+ }
+
+ public void RecordStop(ApplicationLifecycleState state) =>
+ Write(
+ "stop",
+ new Dictionary
+ {
+ ["state"] = state.ToString()
+ });
+
+ public void Dispose()
+ {
+ lock (synchronization)
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+ if (ownsWriter)
+ {
+ writer.Dispose();
+ }
+ }
+ }
+
+ private void Write(
+ string eventType,
+ Dictionary fields)
+ {
+ lock (synchronization)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ fields["timestamp_utc"] = clock.GetUtcNow();
+ fields["event"] = eventType;
+ writer.WriteLine(JsonSerializer.Serialize(fields));
+ writer.Flush();
+ }
+ }
+}
diff --git a/LogiDynamicDash/Diagnostics/SanitizedRs50OledDiagnostics.cs b/LogiDynamicDash/Diagnostics/SanitizedRs50OledDiagnostics.cs
new file mode 100644
index 0000000..b4608b8
--- /dev/null
+++ b/LogiDynamicDash/Diagnostics/SanitizedRs50OledDiagnostics.cs
@@ -0,0 +1,117 @@
+using System.Text.Json;
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Diagnostics;
+
+internal sealed class SanitizedRs50OledDiagnostics(
+ TextWriter writer,
+ TimeProvider? timeProvider = null,
+ bool ownsWriter = false) : IRs50OledDiagnostics
+{
+ private readonly TimeProvider clock =
+ timeProvider ?? TimeProvider.System;
+ private readonly object synchronization = new();
+ private bool disposed;
+
+ internal static SanitizedRs50OledDiagnostics CreateLocal()
+ {
+ string directory = Path.Combine(
+ Environment.GetFolderPath(
+ Environment.SpecialFolder.LocalApplicationData),
+ "LogiDynamicDash",
+ "logs");
+ Directory.CreateDirectory(directory);
+ string timestamp =
+ DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss-fffffff");
+ string path = Path.Combine(
+ directory,
+ $"rs50-oled-{timestamp}.jsonl");
+ StreamWriter writer = new(path, append: false)
+ {
+ AutoFlush = true
+ };
+ return new SanitizedRs50OledDiagnostics(
+ writer,
+ ownsWriter: true);
+ }
+
+ public void RecordOpen(long elapsedMicroseconds) =>
+ Write(
+ "open",
+ new Dictionary
+ {
+ ["result"] = "acknowledged",
+ ["elapsed_microseconds"] = elapsedMicroseconds
+ });
+
+ public void RecordFrame(
+ Rs50OledLayout layout,
+ Rs50OledSendResult result,
+ long elapsedMicroseconds) =>
+ Write(
+ "frame",
+ new Dictionary
+ {
+ ["layout"] = layout.ToString(),
+ ["result"] = ToDiagnosticResult(result),
+ ["elapsed_microseconds"] = elapsedMicroseconds
+ });
+
+ public void RecordFailure(string operation, Type exceptionType)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(operation);
+ ArgumentNullException.ThrowIfNull(exceptionType);
+ Write(
+ "failure",
+ new Dictionary
+ {
+ ["operation"] = operation,
+ ["error_type"] = exceptionType.Name
+ });
+ }
+
+ public void RecordClose() =>
+ Write("close", []);
+
+ public void Dispose()
+ {
+ lock (synchronization)
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+ if (ownsWriter)
+ {
+ writer.Dispose();
+ }
+ }
+ }
+
+ private void Write(
+ string eventType,
+ Dictionary fields)
+ {
+ lock (synchronization)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ fields["timestamp_utc"] = clock.GetUtcNow();
+ fields["event"] = eventType;
+ writer.WriteLine(JsonSerializer.Serialize(fields));
+ writer.Flush();
+ }
+ }
+
+ private static string ToDiagnosticResult(Rs50OledSendResult result) =>
+ result switch
+ {
+ Rs50OledSendResult.Transmitted => "acknowledged",
+ Rs50OledSendResult.Unacknowledged => "unacknowledged",
+ Rs50OledSendResult.Unchanged => "unchanged",
+ Rs50OledSendResult.RateLimited => "rate_limited",
+ _ => throw new ArgumentOutOfRangeException(nameof(result))
+ };
+}
diff --git a/LogiDynamicDash/Displays/AutomaticRs50TelemetryFrameFormatter.cs b/LogiDynamicDash/Displays/AutomaticRs50TelemetryFrameFormatter.cs
new file mode 100644
index 0000000..6238d5a
--- /dev/null
+++ b/LogiDynamicDash/Displays/AutomaticRs50TelemetryFrameFormatter.cs
@@ -0,0 +1,53 @@
+using LogiDynamicDash.Configuration;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Displays;
+
+internal sealed class AutomaticRs50TelemetryFrameFormatter(
+ Rs50OledConfiguration fallback,
+ Rs50ProfileStore profileStore,
+ bool automaticProfiles = true) : IRs50TelemetryFrameFormatter
+{
+ private readonly Dictionary
+ formatters = [];
+
+ public Rs50OledFrame Format(
+ TelemetrySnapshot snapshot,
+ DisplayMode mode)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ Rs50OledConfiguration configuration = Select(snapshot);
+ if (!formatters.TryGetValue(configuration, out var formatter))
+ {
+ formatter = new Rs50TelemetryFrameFormatter(configuration);
+ formatters.Add(configuration, formatter);
+ }
+
+ return formatter.Format(snapshot, mode);
+ }
+
+ internal Rs50OledConfiguration Select(TelemetrySnapshot snapshot)
+ {
+ if (!automaticProfiles ||
+ snapshot.SessionIdentity is not IRacingSessionIdentity identity)
+ {
+ return fallback;
+ }
+
+ Rs50OledConfiguration? stored = profileStore.Resolve(identity);
+ if (stored is not null)
+ {
+ return stored;
+ }
+
+ if (identity.Discipline is
+ IRacingDiscipline.Unknown or IRacingDiscipline.LegacyRoad)
+ {
+ return fallback;
+ }
+
+ return DisciplineProfileRecommendations.Create(
+ identity.Discipline,
+ fallback.SpeedUnit).Configuration;
+ }
+}
diff --git a/LogiDynamicDash/Displays/CompositeApplicationDisplay.cs b/LogiDynamicDash/Displays/CompositeApplicationDisplay.cs
new file mode 100644
index 0000000..0e259b7
--- /dev/null
+++ b/LogiDynamicDash/Displays/CompositeApplicationDisplay.cs
@@ -0,0 +1,104 @@
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Displays;
+
+internal sealed class CompositeApplicationDisplay(
+ params IApplicationDisplay[] displays) : IApplicationDisplay
+{
+ private int initializedCount;
+ private bool stopped;
+
+ public void Initialize()
+ {
+ if (initializedCount != 0 || stopped)
+ {
+ throw new InvalidOperationException(
+ "The composite display cannot be initialized again.");
+ }
+
+ try
+ {
+ foreach (IApplicationDisplay display in displays)
+ {
+ display.Initialize();
+ initializedCount++;
+ }
+ }
+ catch
+ {
+ try
+ {
+ StopInitializedDisplays();
+ }
+ catch
+ {
+ // Preserve the initialization failure after best-effort
+ // cleanup of displays that opened successfully.
+ }
+
+ stopped = true;
+ throw;
+ }
+ }
+
+ public void Render(TelemetrySnapshot snapshot, DisplayMode mode)
+ {
+ if (initializedCount != displays.Length || stopped)
+ {
+ throw new InvalidOperationException(
+ "The composite display is not initialized.");
+ }
+
+ foreach (IApplicationDisplay display in displays)
+ {
+ display.Render(snapshot, mode);
+ }
+ }
+
+ public void Stop()
+ {
+ if (stopped)
+ {
+ return;
+ }
+
+ stopped = true;
+ StopInitializedDisplays();
+ }
+
+ public void Flush()
+ {
+ if (initializedCount != displays.Length || stopped)
+ {
+ throw new InvalidOperationException(
+ "The composite display is not initialized.");
+ }
+
+ foreach (IApplicationDisplay display in displays)
+ {
+ display.Flush();
+ }
+ }
+
+ private void StopInitializedDisplays()
+ {
+ Exception? firstException = null;
+ for (int index = initializedCount - 1; index >= 0; index--)
+ {
+ try
+ {
+ displays[index].Stop();
+ }
+ catch (Exception exception)
+ {
+ firstException ??= exception;
+ }
+ }
+
+ initializedCount = 0;
+ if (firstException is not null)
+ {
+ throw firstException;
+ }
+ }
+}
diff --git a/LogiDynamicDash/Displays/ConsoleDashboard.cs b/LogiDynamicDash/Displays/ConsoleDashboard.cs
index 780d438..9828777 100644
--- a/LogiDynamicDash/Displays/ConsoleDashboard.cs
+++ b/LogiDynamicDash/Displays/ConsoleDashboard.cs
@@ -2,67 +2,110 @@
namespace LogiDynamicDash.Displays;
-internal sealed class ConsoleDashboard
+internal sealed class ConsoleDashboard(bool? interactiveOverride = null)
+ : IApplicationDisplay
{
private const int DashboardWidth = 44;
+ private bool interactive =
+ interactiveOverride ?? !Console.IsOutputRedirected;
public void Initialize()
{
- Console.Title = "LogiDynamicDash";
- Console.CursorVisible = false;
- Console.Clear();
+ if (!interactive)
+ {
+ return;
+ }
+
+ try
+ {
+ Console.Title = "LogiDynamicDash";
+ Console.CursorVisible = false;
+ Console.Clear();
+ }
+ catch (IOException)
+ {
+ interactive = false;
+ }
}
public void Render(
TelemetrySnapshot snapshot,
DisplayMode mode)
{
- Console.SetCursorPosition(0, 0);
+ if (!interactive)
+ {
+ return;
+ }
- WriteDashboardLine(
- new string('=', DashboardWidth));
+ try
+ {
+ Console.SetCursorPosition(0, 0);
- WriteCentered(
- "LOGIDYNAMICDASH OLED PREVIEW");
+ WriteDashboardLine(
+ new string('=', DashboardWidth));
- WriteDashboardLine(
- new string('=', DashboardWidth));
+ WriteCentered(
+ "LOGIDYNAMICDASH OLED PREVIEW");
- WriteDashboardLine();
+ WriteDashboardLine(
+ new string('=', DashboardWidth));
- switch (mode)
- {
- case DisplayMode.BrakeBias:
- RenderBrakeBias(snapshot);
- break;
+ WriteDashboardLine();
+
+ switch (mode)
+ {
+ case DisplayMode.BrakeBias:
+ RenderBrakeBias(snapshot);
+ break;
- case DisplayMode.LastLap:
- RenderLastLap(snapshot);
- break;
+ case DisplayMode.LastLap:
+ RenderLastLap(snapshot);
+ break;
- case DisplayMode.ConnectionProblem:
- RenderConnectionProblem(snapshot);
- break;
+ case DisplayMode.ConnectionProblem:
+ RenderConnectionProblem(snapshot);
+ break;
- default:
- RenderNormal(snapshot);
- break;
- }
+ default:
+ RenderNormal(snapshot);
+ break;
+ }
- WriteDashboardLine(
- new string('-', DashboardWidth));
+ WriteDashboardLine(
+ new string('-', DashboardWidth));
- WriteDashboardLine(
- "Press Ctrl+C to stop.");
+ WriteDashboardLine(
+ "Press Ctrl+C to stop.");
+ }
+ catch (IOException)
+ {
+ interactive = false;
+ }
}
public void Stop()
{
- Console.CursorVisible = true;
- Console.Clear();
+ if (!interactive)
+ {
+ return;
+ }
- Console.WriteLine(
- "Telemetry monitoring stopped.");
+ try
+ {
+ Console.CursorVisible = true;
+ Console.Clear();
+
+ Console.WriteLine(
+ "Telemetry monitoring stopped.");
+ }
+ catch (IOException)
+ {
+ // A disappearing host console must not mask safe OLED shutdown.
+ }
+ finally
+ {
+ interactive = false;
+ }
}
private static void RenderNormal(
@@ -78,8 +121,28 @@ snapshot.SpeedMetersPerSecond is float metersPerSecond
WriteCentered($"GEAR {gear}");
WriteCentered(speed);
- WriteDashboardLine();
- WriteDashboardLine();
+ if (snapshot.SessionIdentity is IRacingSessionIdentity identity)
+ {
+ string? car = identity.Car?.ShortName;
+ if (string.IsNullOrWhiteSpace(car))
+ {
+ car = identity.Car?.DisplayName;
+ }
+
+ WriteCentered(
+ Truncate(
+ $"{IRacingDisciplineDisplay.Name(identity.Discipline)}"));
+ WriteCentered(
+ Truncate(
+ string.IsNullOrWhiteSpace(car)
+ ? "CAR UNKNOWN"
+ : $"CAR {car}"));
+ }
+ else
+ {
+ WriteDashboardLine();
+ WriteDashboardLine();
+ }
}
private static void RenderBrakeBias(
@@ -151,6 +214,11 @@ private static string FormatLapTime(
$"{minutes}:{time.Seconds:00}.{time.Milliseconds:000}";
}
+ private static string Truncate(string value) =>
+ value.Length <= DashboardWidth
+ ? value
+ : value[..DashboardWidth];
+
private static string FormatGear(
int? gear)
{
@@ -193,4 +261,4 @@ private static void WriteDashboardLine(
Console.WriteLine(
text.PadRight(DashboardWidth));
}
-}
\ No newline at end of file
+}
diff --git a/LogiDynamicDash/Displays/DashboardStatusDisplay.cs b/LogiDynamicDash/Displays/DashboardStatusDisplay.cs
new file mode 100644
index 0000000..3a5a1ea
--- /dev/null
+++ b/LogiDynamicDash/Displays/DashboardStatusDisplay.cs
@@ -0,0 +1,44 @@
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Runtime;
+
+namespace LogiDynamicDash.Displays;
+
+internal sealed class DashboardStatusDisplay(
+ Action statusChanged) : IApplicationDisplay
+{
+ private TelemetrySnapshot? previous;
+
+ public void Initialize()
+ {
+ }
+
+ public void Render(TelemetrySnapshot snapshot, DisplayMode mode)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ if (SameStatus(previous, snapshot))
+ {
+ return;
+ }
+
+ previous = snapshot.Copy();
+ statusChanged(snapshot.Copy());
+ }
+
+ public void Flush()
+ {
+ }
+
+ public void Stop()
+ {
+ }
+
+ private static bool SameStatus(
+ TelemetrySnapshot? left,
+ TelemetrySnapshot right) =>
+ left is not null &&
+ string.Equals(
+ left.ConnectionState,
+ right.ConnectionState,
+ StringComparison.OrdinalIgnoreCase) &&
+ Equals(left.SessionIdentity, right.SessionIdentity);
+}
diff --git a/LogiDynamicDash/Displays/IApplicationDisplay.cs b/LogiDynamicDash/Displays/IApplicationDisplay.cs
new file mode 100644
index 0000000..ede5264
--- /dev/null
+++ b/LogiDynamicDash/Displays/IApplicationDisplay.cs
@@ -0,0 +1,16 @@
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Displays;
+
+internal interface IApplicationDisplay
+{
+ void Initialize();
+
+ void Render(TelemetrySnapshot snapshot, DisplayMode mode);
+
+ void Flush()
+ {
+ }
+
+ void Stop();
+}
diff --git a/LogiDynamicDash/Displays/IRs50TelemetryFrameFormatter.cs b/LogiDynamicDash/Displays/IRs50TelemetryFrameFormatter.cs
new file mode 100644
index 0000000..62cb4ba
--- /dev/null
+++ b/LogiDynamicDash/Displays/IRs50TelemetryFrameFormatter.cs
@@ -0,0 +1,8 @@
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Displays;
+
+internal interface IRs50TelemetryFrameFormatter
+{
+ Rs50OledFrame Format(TelemetrySnapshot snapshot, DisplayMode mode);
+}
diff --git a/LogiDynamicDash/Displays/RecoveringRs50OledDisplaySink.cs b/LogiDynamicDash/Displays/RecoveringRs50OledDisplaySink.cs
new file mode 100644
index 0000000..dee872b
--- /dev/null
+++ b/LogiDynamicDash/Displays/RecoveringRs50OledDisplaySink.cs
@@ -0,0 +1,192 @@
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Runtime;
+
+namespace LogiDynamicDash.Displays;
+
+internal sealed class RecoveringRs50OledDisplaySink(
+ Func sessionFactory,
+ IRs50TelemetryFrameFormatter formatter,
+ Action stateChanged,
+ TimeProvider? timeProvider = null) : IApplicationDisplay
+{
+ private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2);
+ private readonly object synchronization = new();
+ private readonly TimeProvider clock = timeProvider ?? TimeProvider.System;
+ private IRs50OledSession? session;
+ private Rs50OledFrameScheduler? scheduler;
+ private Rs50OledFrame? pendingFrame;
+ private bool pendingUrgent;
+ private long retryAfter;
+ private bool initialized;
+ private bool stopped;
+ private DashboardOledState state = DashboardOledState.Waiting;
+
+ public void Initialize()
+ {
+ lock (synchronization)
+ {
+ if (initialized || stopped)
+ {
+ throw new InvalidOperationException(
+ "The production OLED display cannot be initialized again.");
+ }
+
+ initialized = true;
+ SetState(DashboardOledState.Waiting);
+ TryConnect(force: true);
+ }
+ }
+
+ public void Render(TelemetrySnapshot snapshot, DisplayMode mode)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ lock (synchronization)
+ {
+ RequireRunning();
+ pendingFrame = formatter.Format(snapshot, mode);
+ pendingUrgent = mode == DisplayMode.ConnectionProblem;
+ TrySend();
+ }
+ }
+
+ public void Flush()
+ {
+ lock (synchronization)
+ {
+ RequireRunning();
+ if (scheduler is null)
+ {
+ TryConnect(force: false);
+ TrySend();
+ return;
+ }
+
+ try
+ {
+ scheduler.Flush();
+ }
+ catch (Exception exception) when (IsDeviceFailure(exception))
+ {
+ LoseConnection();
+ }
+ }
+ }
+
+ public void Stop()
+ {
+ lock (synchronization)
+ {
+ if (stopped)
+ {
+ return;
+ }
+
+ stopped = true;
+ DisposeSession();
+ pendingFrame = null;
+ SetState(DashboardOledState.Stopped);
+ }
+ }
+
+ private void TrySend()
+ {
+ if (scheduler is null)
+ {
+ TryConnect(force: false);
+ }
+
+ if (scheduler is null || pendingFrame is null)
+ {
+ return;
+ }
+
+ try
+ {
+ scheduler.Submit(pendingFrame, pendingUrgent);
+ }
+ catch (Exception exception) when (IsDeviceFailure(exception))
+ {
+ LoseConnection();
+ }
+ }
+
+ private void TryConnect(bool force)
+ {
+ if (scheduler is not null)
+ {
+ return;
+ }
+
+ long now = clock.GetTimestamp();
+ if (!force && retryAfter != 0 && now < retryAfter)
+ {
+ return;
+ }
+
+ IRs50OledSession? created = null;
+ try
+ {
+ created = sessionFactory();
+ created.Open();
+ session = created;
+ scheduler = new Rs50OledFrameScheduler(created);
+ retryAfter = 0;
+ SetState(DashboardOledState.Connected);
+ }
+ catch (Exception exception) when (IsDeviceFailure(exception))
+ {
+ created?.Dispose();
+ retryAfter = now + ToTimestampTicks(RetryDelay);
+ SetState(
+ state == DashboardOledState.Waiting
+ ? DashboardOledState.Waiting
+ : DashboardOledState.Reconnecting);
+ }
+ }
+
+ private void LoseConnection()
+ {
+ DisposeSession();
+ retryAfter =
+ clock.GetTimestamp() + ToTimestampTicks(RetryDelay);
+ SetState(DashboardOledState.Reconnecting);
+ }
+
+ private long ToTimestampTicks(TimeSpan duration) =>
+ (long)(duration.TotalSeconds * clock.TimestampFrequency);
+
+ private void DisposeSession()
+ {
+ scheduler = null;
+ session?.Dispose();
+ session = null;
+ }
+
+ private void SetState(DashboardOledState next)
+ {
+ if (state == next && next != DashboardOledState.Waiting)
+ {
+ return;
+ }
+
+ state = next;
+ stateChanged(next);
+ }
+
+ private void RequireRunning()
+ {
+ if (!initialized || stopped)
+ {
+ throw new InvalidOperationException(
+ "The production OLED display is not running.");
+ }
+ }
+
+ private static bool IsDeviceFailure(Exception exception) =>
+ exception is IOException or
+ TimeoutException or
+ UnauthorizedAccessException or
+ InvalidOperationException or
+ Rs50OledProtocolException;
+}
diff --git a/LogiDynamicDash/Displays/Rs50OledDisplaySink.cs b/LogiDynamicDash/Displays/Rs50OledDisplaySink.cs
new file mode 100644
index 0000000..b574eb8
--- /dev/null
+++ b/LogiDynamicDash/Displays/Rs50OledDisplaySink.cs
@@ -0,0 +1,167 @@
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Displays;
+
+internal enum OledDeviceState
+{
+ Disabled,
+ Opening,
+ Active,
+ Faulted,
+ Stopped
+}
+
+///
+/// Bounded physical-validation sink. Telemetry outside the configured speed
+/// envelope fails closed before a frame is formatted or transmitted.
+///
+internal sealed class Rs50OledDisplaySink(
+ Func sessionFactory,
+ IRs50TelemetryFrameFormatter formatter,
+ float? maximumPermittedSpeedMetersPerSecond =
+ Rs50OledDisplaySink.MaximumStationarySpeedMetersPerSecond)
+ : IApplicationDisplay
+{
+ internal const float MaximumStationarySpeedMetersPerSecond = 0.5f;
+
+ private readonly object synchronization = new();
+ private readonly float? maximumSpeedMetersPerSecond =
+ ValidateMaximumSpeed(maximumPermittedSpeedMetersPerSecond);
+ private IRs50OledSession? session;
+ private Rs50OledFrameScheduler? scheduler;
+
+ internal OledDeviceState State { get; private set; } =
+ OledDeviceState.Disabled;
+
+ public void Initialize()
+ {
+ lock (synchronization)
+ {
+ if (State != OledDeviceState.Disabled)
+ {
+ throw new InvalidOperationException(
+ "The RS50 OLED display sink cannot be initialized again.");
+ }
+
+ State = OledDeviceState.Opening;
+ IRs50OledSession? created = null;
+ try
+ {
+ created = sessionFactory();
+ created.Open();
+ session = created;
+ scheduler = new Rs50OledFrameScheduler(created);
+ State = OledDeviceState.Active;
+ }
+ catch
+ {
+ State = OledDeviceState.Faulted;
+ created?.Dispose();
+ throw;
+ }
+ }
+ }
+
+ public void Render(TelemetrySnapshot snapshot, DisplayMode mode)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ lock (synchronization)
+ {
+ if (State != OledDeviceState.Active || scheduler is null)
+ {
+ throw new InvalidOperationException(
+ "The RS50 OLED display sink is not initialized.");
+ }
+
+ try
+ {
+ RequireInsideSpeedEnvelope(snapshot);
+ Rs50OledFrame frame = formatter.Format(snapshot, mode);
+ scheduler.Submit(
+ frame,
+ mode == DisplayMode.ConnectionProblem);
+ }
+ catch
+ {
+ State = OledDeviceState.Faulted;
+ throw;
+ }
+ }
+ }
+
+ public void Stop()
+ {
+ lock (synchronization)
+ {
+ if (State == OledDeviceState.Stopped)
+ {
+ return;
+ }
+
+ State = OledDeviceState.Stopped;
+ session?.Dispose();
+ session = null;
+ scheduler = null;
+ }
+ }
+
+ public void Flush()
+ {
+ lock (synchronization)
+ {
+ if (State != OledDeviceState.Active || scheduler is null)
+ {
+ throw new InvalidOperationException(
+ "The RS50 OLED display sink is not initialized.");
+ }
+
+ try
+ {
+ scheduler.Flush();
+ }
+ catch
+ {
+ State = OledDeviceState.Faulted;
+ throw;
+ }
+ }
+ }
+
+ private void RequireInsideSpeedEnvelope(TelemetrySnapshot snapshot)
+ {
+ if (snapshot.IsOnTrack != true)
+ {
+ return;
+ }
+
+ if (snapshot.SpeedMetersPerSecond is not float speed ||
+ !float.IsFinite(speed) ||
+ speed < 0)
+ {
+ throw new InvalidOperationException(
+ "RS50 OLED output stopped because on-track speed telemetry " +
+ "was missing or invalid.");
+ }
+
+ if (maximumSpeedMetersPerSecond is float maximumSpeed &&
+ speed > maximumSpeed)
+ {
+ throw new InvalidOperationException(
+ "RS50 OLED output stopped because moving-car telemetry was " +
+ "outside the explicitly armed speed limit.");
+ }
+ }
+
+ private static float? ValidateMaximumSpeed(float? maximumSpeed)
+ {
+ if (maximumSpeed is float value &&
+ (!float.IsFinite(value) || value <= 0))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(maximumPermittedSpeedMetersPerSecond));
+ }
+
+ return maximumSpeed;
+ }
+}
diff --git a/LogiDynamicDash/Displays/Rs50OledFrameScheduler.cs b/LogiDynamicDash/Displays/Rs50OledFrameScheduler.cs
new file mode 100644
index 0000000..34e4255
--- /dev/null
+++ b/LogiDynamicDash/Displays/Rs50OledFrameScheduler.cs
@@ -0,0 +1,74 @@
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Displays;
+
+///
+/// Serializes OLED submissions and retains one latest pending frame. A
+/// connection-problem frame cannot be replaced by ordinary telemetry before
+/// it is acknowledged.
+///
+internal sealed class Rs50OledFrameScheduler(IRs50OledSession session)
+{
+ private Rs50OledFrame? pendingFrame;
+ private bool pendingIsCritical;
+
+ internal bool HasPendingFrame => pendingFrame is not null;
+
+ internal void Submit(Rs50OledFrame frame, bool isCritical)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+
+ if (pendingFrame is not null)
+ {
+ Rs50OledFrame queued = pendingFrame;
+ Rs50OledSendResult pendingResult = session.Send(queued);
+ if (pendingResult == Rs50OledSendResult.RateLimited)
+ {
+ Queue(frame, isCritical);
+ return;
+ }
+
+ pendingFrame = null;
+ pendingIsCritical = false;
+ if (queued == frame)
+ {
+ return;
+ }
+
+ }
+
+ Rs50OledSendResult result = session.Send(frame);
+ if (result == Rs50OledSendResult.RateLimited)
+ {
+ pendingFrame = frame;
+ pendingIsCritical = isCritical;
+ }
+ }
+
+ internal void Flush()
+ {
+ if (pendingFrame is null)
+ {
+ return;
+ }
+
+ Rs50OledSendResult result = session.Send(pendingFrame);
+ if (result != Rs50OledSendResult.RateLimited)
+ {
+ pendingFrame = null;
+ pendingIsCritical = false;
+ }
+ }
+
+ private void Queue(Rs50OledFrame frame, bool isCritical)
+ {
+ if (pendingIsCritical && !isCritical)
+ {
+ return;
+ }
+
+ pendingFrame = frame;
+ pendingIsCritical = isCritical;
+ }
+}
diff --git a/LogiDynamicDash/Displays/Rs50TelemetryFrameFormatter.cs b/LogiDynamicDash/Displays/Rs50TelemetryFrameFormatter.cs
new file mode 100644
index 0000000..6fc1ba5
--- /dev/null
+++ b/LogiDynamicDash/Displays/Rs50TelemetryFrameFormatter.cs
@@ -0,0 +1,331 @@
+using System.Globalization;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Displays;
+
+///
+/// Maps application telemetry to the fixed semantic fields exposed by the
+/// confirmed RS50 OLED layouts. It does not encode or transmit HID data.
+///
+internal sealed class Rs50TelemetryFrameFormatter(
+ Rs50OledConfiguration configuration) : IRs50TelemetryFrameFormatter
+{
+ public Rs50OledFrame Format(
+ TelemetrySnapshot snapshot,
+ DisplayMode mode)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ Rs50OledLayout layout = configuration.LayoutFor(mode);
+ return layout switch
+ {
+ Rs50OledLayout.A => new Rs50LayoutAFrame(),
+ Rs50OledLayout.B => new Rs50LayoutBFrame(),
+ Rs50OledLayout.C => new Rs50LayoutCFrame(RpmGauge(snapshot)),
+ Rs50OledLayout.D => FormatLayoutD(snapshot, mode),
+ Rs50OledLayout.E => FormatLayoutE(snapshot, mode),
+ Rs50OledLayout.F => FormatCompact(snapshot, mode, layoutF: true),
+ Rs50OledLayout.G => FormatCompact(snapshot, mode, layoutF: false),
+ Rs50OledLayout.H => FormatLayoutH(snapshot, mode),
+ Rs50OledLayout.I => FormatFourRows(snapshot, mode, layoutI: true),
+ Rs50OledLayout.J => FormatFourRows(snapshot, mode, layoutI: false),
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(mode),
+ "Unsupported RS50 OLED layout.")
+ };
+ }
+
+ private Rs50LayoutDFrame FormatLayoutD(
+ TelemetrySnapshot snapshot,
+ DisplayMode mode) =>
+ new(
+ RpmGauge(snapshot),
+ SpeedGauge(snapshot),
+ mode switch
+ {
+ DisplayMode.BrakeBias =>
+ $"BB {FormatBrakeBias(snapshot.BrakeBiasPercent)}",
+ DisplayMode.LastLap =>
+ $"L {FormatLapTime(snapshot.LastLapTimeSeconds)}",
+ DisplayMode.ConnectionProblem =>
+ "OFFLINE",
+ _ =>
+ $"{FormatGear(snapshot.Gear)} " +
+ $"{FormatSpeedNumber(snapshot.SpeedMetersPerSecond)}" +
+ SpeedUnitSuffix(compact: true)
+ });
+
+ private Rs50LayoutEFrame FormatLayoutE(
+ TelemetrySnapshot snapshot,
+ DisplayMode mode)
+ {
+ (string left, string right) = mode switch
+ {
+ DisplayMode.BrakeBias =>
+ (FormatBrakeBias(snapshot.BrakeBiasPercent), "BB"),
+ DisplayMode.LastLap =>
+ (FormatLapTimeShort(snapshot.LastLapTimeSeconds), "LAP"),
+ DisplayMode.ConnectionProblem =>
+ ("OFFLINE", "ERR"),
+ _ =>
+ (FormatSpeed(snapshot.SpeedMetersPerSecond),
+ FormatGear(snapshot.Gear))
+ };
+
+ return new Rs50LayoutEFrame(
+ RpmGauge(snapshot),
+ SpeedGauge(snapshot),
+ left,
+ right);
+ }
+
+ private Rs50OledFrame FormatCompact(
+ TelemetrySnapshot snapshot,
+ DisplayMode mode,
+ bool layoutF)
+ {
+ (string left, string right) = mode switch
+ {
+ DisplayMode.BrakeBias =>
+ ("B", FormatBrakeBiasWhole(snapshot.BrakeBiasPercent)),
+ DisplayMode.LastLap =>
+ ("L", FormatLapSeconds(snapshot.LastLapTimeSeconds)),
+ DisplayMode.ConnectionProblem =>
+ ("!", "ERR"),
+ _ =>
+ (FormatGearOneCharacter(snapshot.Gear),
+ FormatSpeedNumber(snapshot.SpeedMetersPerSecond))
+ };
+
+ return layoutF
+ ? new Rs50LayoutFFrame(left, right)
+ : new Rs50LayoutGFrame(left, right);
+ }
+
+ private Rs50LayoutHFrame FormatLayoutH(
+ TelemetrySnapshot snapshot,
+ DisplayMode mode) =>
+ mode switch
+ {
+ DisplayMode.BrakeBias =>
+ new("BRAKE BIAS", FormatBrakeBias(snapshot.BrakeBiasPercent)),
+ DisplayMode.LastLap =>
+ new("LAST LAP", FormatLapTime(snapshot.LastLapTimeSeconds)),
+ DisplayMode.ConnectionProblem =>
+ new("IRACING", FormatConnection(snapshot.ConnectionState)),
+ _ =>
+ new(
+ $"SPEED {FormatSpeed(snapshot.SpeedMetersPerSecond)}",
+ $"GEAR {FormatGear(snapshot.Gear)}")
+ };
+
+ private Rs50OledFrame FormatFourRows(
+ TelemetrySnapshot snapshot,
+ DisplayMode mode,
+ bool layoutI)
+ {
+ (string line1, string line2, string line3, string line4) =
+ mode switch
+ {
+ DisplayMode.BrakeBias =>
+ ("BRAKE BIAS",
+ FormatBrakeBias(snapshot.BrakeBiasPercent),
+ string.Empty,
+ string.Empty),
+ DisplayMode.LastLap =>
+ ("LAST LAP",
+ FormatLapTime(snapshot.LastLapTimeSeconds),
+ string.Empty,
+ string.Empty),
+ DisplayMode.ConnectionProblem =>
+ ("IRACING",
+ FormatConnection(snapshot.ConnectionState),
+ string.Empty,
+ string.Empty),
+ _ =>
+ ("SPEED",
+ FormatSpeed(snapshot.SpeedMetersPerSecond),
+ "GEAR",
+ FormatGear(snapshot.Gear))
+ };
+
+ return layoutI
+ ? new Rs50LayoutIFrame(line1, line2, line3, line4)
+ : new Rs50LayoutJFrame(line1, line2, line3, line4);
+ }
+
+ private Rs50GaugeLevel RpmGauge(TelemetrySnapshot snapshot)
+ {
+ double ratio =
+ snapshot.Rpm is float rpm && float.IsFinite(rpm) && rpm > 0
+ ? rpm / configuration.MaximumRpm
+ : 0;
+ return Rs50GaugeLevel.FromRatio(ratio);
+ }
+
+ private Rs50GaugeLevel SpeedGauge(TelemetrySnapshot snapshot)
+ {
+ double speed = ConvertSpeed(snapshot.SpeedMetersPerSecond);
+ double ratio = speed >= 0
+ ? speed / configuration.GaugeMaximumSpeed
+ : 0;
+ return Rs50GaugeLevel.FromRatio(ratio);
+ }
+
+ private string FormatSpeed(float? metersPerSecond) =>
+ $"{FormatSpeedNumber(metersPerSecond)}{SpeedUnitSuffix(false)}";
+
+ private string FormatSpeedNumber(float? metersPerSecond)
+ {
+ double speed = ConvertSpeed(metersPerSecond);
+ if (speed < 0)
+ {
+ return "---";
+ }
+
+ int rounded = Math.Clamp(
+ (int)Math.Round(speed, MidpointRounding.AwayFromZero),
+ 0,
+ 999);
+ return rounded.ToString(CultureInfo.InvariantCulture);
+ }
+
+ private double ConvertSpeed(float? metersPerSecond)
+ {
+ if (metersPerSecond is not float value ||
+ !float.IsFinite(value) ||
+ value < 0)
+ {
+ return -1;
+ }
+
+ return configuration.SpeedUnit switch
+ {
+ SpeedUnit.KilometersPerHour => value * 3.6,
+ SpeedUnit.MilesPerHour => value * 2.2369362920544,
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(configuration))
+ };
+ }
+
+ private string SpeedUnitSuffix(bool compact) =>
+ configuration.SpeedUnit switch
+ {
+ SpeedUnit.KilometersPerHour => compact ? "K" : " KMH",
+ SpeedUnit.MilesPerHour => compact ? "M" : " MPH",
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(configuration))
+ };
+
+ private static string FormatGear(int? gear) => gear switch
+ {
+ -1 => "R",
+ 0 => "N",
+ >= 1 and <= 99 => gear.Value.ToString(CultureInfo.InvariantCulture),
+ _ => "?"
+ };
+
+ private static string FormatGearOneCharacter(int? gear) => gear switch
+ {
+ -1 => "R",
+ 0 => "N",
+ >= 1 and <= 9 => gear.Value.ToString(CultureInfo.InvariantCulture),
+ _ => "?"
+ };
+
+ private static string FormatBrakeBias(float? percent)
+ {
+ if (percent is not float value ||
+ !float.IsFinite(value) ||
+ value is < 0 or > 100)
+ {
+ return "N/A";
+ }
+
+ return value.ToString("F1", CultureInfo.InvariantCulture) + "%";
+ }
+
+ private static string FormatBrakeBiasWhole(float? percent)
+ {
+ if (percent is not float value ||
+ !float.IsFinite(value) ||
+ value is < 0 or > 100)
+ {
+ return "---";
+ }
+
+ return Math.Clamp(
+ (int)Math.Round(value, MidpointRounding.AwayFromZero),
+ 0,
+ 100)
+ .ToString(CultureInfo.InvariantCulture);
+ }
+
+ private static string FormatLapTime(float? totalSeconds)
+ {
+ if (!TryCreateLapTime(totalSeconds, out TimeSpan time))
+ {
+ return "N/A";
+ }
+
+ int minutes = (int)time.TotalMinutes;
+ return
+ $"{minutes.ToString(CultureInfo.InvariantCulture)}:" +
+ $"{time.Seconds:00}.{time.Milliseconds:000}";
+ }
+
+ private static string FormatLapTimeShort(float? totalSeconds)
+ {
+ if (!TryCreateLapTime(totalSeconds, out TimeSpan time))
+ {
+ return "N/A";
+ }
+
+ int minutes = (int)time.TotalMinutes;
+ int tenths = time.Milliseconds / 100;
+ return
+ $"{minutes.ToString(CultureInfo.InvariantCulture)}:" +
+ $"{time.Seconds:00}.{tenths}";
+ }
+
+ private static string FormatLapSeconds(float? totalSeconds)
+ {
+ if (totalSeconds is not float value ||
+ !float.IsFinite(value) ||
+ value <= 0)
+ {
+ return "---";
+ }
+
+ return Math.Clamp(
+ (int)Math.Round(value, MidpointRounding.AwayFromZero),
+ 0,
+ 999)
+ .ToString(CultureInfo.InvariantCulture);
+ }
+
+ private static bool TryCreateLapTime(
+ float? totalSeconds,
+ out TimeSpan time)
+ {
+ if (totalSeconds is not float value ||
+ !float.IsFinite(value) ||
+ value <= 0 ||
+ value >= 6000)
+ {
+ time = default;
+ return false;
+ }
+
+ time = TimeSpan.FromSeconds(value);
+ return true;
+ }
+
+ private static string FormatConnection(string state) =>
+ state.ToUpperInvariant() switch
+ {
+ "WAITING" => "WAITING",
+ "ERROR" => "ERROR",
+ _ => "OFFLINE"
+ };
+}
diff --git a/LogiDynamicDash/Hidpp/IRs50OledExchange.cs b/LogiDynamicDash/Hidpp/IRs50OledExchange.cs
new file mode 100644
index 0000000..ebee815
--- /dev/null
+++ b/LogiDynamicDash/Hidpp/IRs50OledExchange.cs
@@ -0,0 +1,6 @@
+namespace LogiDynamicDash.Hidpp;
+
+internal interface IRs50OledExchange : IDisposable
+{
+ byte[] Exchange(Rs50OledTransaction transaction);
+}
diff --git a/LogiDynamicDash/Hidpp/Rs50OledProtocol.cs b/LogiDynamicDash/Hidpp/Rs50OledProtocol.cs
new file mode 100644
index 0000000..004a9b5
--- /dev/null
+++ b/LogiDynamicDash/Hidpp/Rs50OledProtocol.cs
@@ -0,0 +1,339 @@
+using System.Text;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Hidpp;
+
+///
+/// Closed encoder and response validator for the confirmed public HID++
+/// Display Game Data feature. This type has no device or stream access.
+///
+internal static class Rs50OledProtocol
+{
+ internal const ushort DisplayFeatureId = 0x8130;
+ internal const int ShortReportLength = 7;
+ internal const int VeryLongReportLength = 64;
+
+ private const byte SoftwareId = 0x0A;
+ private const byte DeviceIndex = 0xFF;
+ private const byte ShortReportId = 0x10;
+ private const byte VeryLongReportId = 0x12;
+ private const byte RootFeatureIndex = 0x00;
+ private const byte RootGetFeatureFunction = 0x00;
+ private const byte SetLayoutFunction = 0x03;
+
+ internal static Rs50OledTransaction CreateDiscovery()
+ {
+ byte[] request =
+ [
+ ShortReportId,
+ DeviceIndex,
+ RootFeatureIndex,
+ EncodeFunction(RootGetFeatureFunction),
+ (byte)(DisplayFeatureId >> 8),
+ (byte)(DisplayFeatureId & 0xFF),
+ 0
+ ];
+
+ return Rs50OledTransaction.Discovery(request);
+ }
+
+ internal static byte ParseDiscoveryResponse(
+ ReadOnlySpan response)
+ {
+ RequireResponseLength(response);
+ ThrowIfHidppError(
+ response,
+ RootFeatureIndex,
+ EncodeFunction(RootGetFeatureFunction));
+ RequireHeader(
+ response,
+ RootFeatureIndex,
+ EncodeFunction(RootGetFeatureFunction));
+
+ byte runtimeIndex = response[4];
+ if (!IsRuntimeIndex(runtimeIndex))
+ {
+ throw new Rs50OledProtocolException(
+ $"The device returned invalid runtime index " +
+ $"0x{runtimeIndex:X2}.");
+ }
+
+ if (response[5] != 0)
+ {
+ throw new Rs50OledProtocolException(
+ "Display Game Data is not a public feature.");
+ }
+
+ if (response[6] != 0)
+ {
+ throw new Rs50OledProtocolException(
+ $"Unsupported Display Game Data version {response[6]}.");
+ }
+
+ RequireZero(response[7..], "discovery padding");
+ return runtimeIndex;
+ }
+
+ internal static Rs50OledTransaction CreateLayout(
+ byte runtimeIndex,
+ Rs50OledFrame frame)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ RequireRuntimeIndex(runtimeIndex);
+
+ byte[] request = new byte[VeryLongReportLength];
+ request[0] = VeryLongReportId;
+ request[1] = DeviceIndex;
+ request[2] = runtimeIndex;
+ request[3] = EncodeFunction(SetLayoutFunction);
+ request[4] = (byte)frame.Layout;
+
+ Rs50OledTransactionKind kind = frame switch
+ {
+ Rs50LayoutAFrame =>
+ Rs50OledTransactionKind.SetLayoutA,
+ Rs50LayoutBFrame =>
+ Rs50OledTransactionKind.SetLayoutB,
+ Rs50LayoutCFrame value =>
+ EncodeLayoutC(request, value),
+ Rs50LayoutDFrame value =>
+ EncodeLayoutD(request, value),
+ Rs50LayoutEFrame value =>
+ EncodeLayoutE(request, value),
+ Rs50LayoutFFrame value =>
+ EncodeTwoText(
+ request,
+ value.LeftText,
+ 1,
+ value.RightText,
+ 3,
+ Rs50OledTransactionKind.SetLayoutF),
+ Rs50LayoutGFrame value =>
+ EncodeTwoText(
+ request,
+ value.LeftText,
+ 1,
+ value.RightText,
+ 3,
+ Rs50OledTransactionKind.SetLayoutG),
+ Rs50LayoutHFrame value =>
+ EncodeTwoText(
+ request,
+ value.TopText,
+ 21,
+ value.BottomText,
+ 10,
+ Rs50OledTransactionKind.SetLayoutH),
+ Rs50LayoutIFrame value =>
+ EncodeFourText(
+ request,
+ value.Line1,
+ value.Line2,
+ value.Line3,
+ value.Line4,
+ Rs50OledTransactionKind.SetLayoutI),
+ Rs50LayoutJFrame value =>
+ EncodeFourText(
+ request,
+ value.Line1,
+ value.Line2,
+ value.Line3,
+ value.Line4,
+ Rs50OledTransactionKind.SetLayoutJ),
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(frame),
+ "Unknown OLED frame type.")
+ };
+
+ return Rs50OledTransaction.Layout(kind, request);
+ }
+
+ internal static void ParseLayoutAcknowledgement(
+ Rs50OledTransaction transaction,
+ ReadOnlySpan response)
+ {
+ ArgumentNullException.ThrowIfNull(transaction);
+ if (transaction.Kind is
+ Rs50OledTransactionKind.DiscoverDisplayFeature)
+ {
+ throw new ArgumentException(
+ "A discovery transaction cannot validate a layout response.",
+ nameof(transaction));
+ }
+
+ ReadOnlySpan request = transaction.RequestSpan;
+ RequireResponseLength(response);
+ ThrowIfHidppError(response, request[2], request[3]);
+ RequireHeader(response, request[2], request[3]);
+ RequireZero(response[4..], "layout acknowledgement");
+ }
+
+ private static Rs50OledTransactionKind EncodeLayoutC(
+ Span request,
+ Rs50LayoutCFrame frame)
+ {
+ request[5] = frame.MainGauge.WireValue;
+ return Rs50OledTransactionKind.SetLayoutC;
+ }
+
+ private static Rs50OledTransactionKind EncodeLayoutD(
+ Span request,
+ Rs50LayoutDFrame frame)
+ {
+ request[5] = frame.MainGauge.WireValue;
+ request[6] = frame.ThinIndicator.WireValue;
+ WriteText(request.Slice(7, 11), frame.Text, nameof(frame.Text));
+ return Rs50OledTransactionKind.SetLayoutD;
+ }
+
+ private static Rs50OledTransactionKind EncodeLayoutE(
+ Span request,
+ Rs50LayoutEFrame frame)
+ {
+ request[5] = frame.MainGauge.WireValue;
+ request[6] = frame.ThinIndicator.WireValue;
+
+ // Layout E's confirmed visual order is the reverse of its wire order.
+ WriteText(
+ request.Slice(7, 3),
+ frame.RightText,
+ nameof(frame.RightText));
+ WriteText(
+ request.Slice(10, 7),
+ frame.LeftText,
+ nameof(frame.LeftText));
+ return Rs50OledTransactionKind.SetLayoutE;
+ }
+
+ private static Rs50OledTransactionKind EncodeTwoText(
+ Span request,
+ string first,
+ int firstLength,
+ string second,
+ int secondLength,
+ Rs50OledTransactionKind kind)
+ {
+ WriteText(request.Slice(5, firstLength), first, nameof(first));
+ WriteText(
+ request.Slice(5 + firstLength, secondLength),
+ second,
+ nameof(second));
+ return kind;
+ }
+
+ private static Rs50OledTransactionKind EncodeFourText(
+ Span request,
+ string line1,
+ string line2,
+ string line3,
+ string line4,
+ Rs50OledTransactionKind kind)
+ {
+ WriteText(request.Slice(5, 19), line1, nameof(line1));
+ WriteText(request.Slice(24, 10), line2, nameof(line2));
+ WriteText(request.Slice(34, 19), line3, nameof(line3));
+ WriteText(request.Slice(53, 10), line4, nameof(line4));
+ return kind;
+ }
+
+ private static void WriteText(
+ Span destination,
+ string value,
+ string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(value, parameterName);
+ if (value.Length > destination.Length)
+ {
+ throw new ArgumentException(
+ $"Text exceeds this layout field's " +
+ $"{destination.Length}-character limit.",
+ parameterName);
+ }
+
+ if (value.Any(character =>
+ character is < (char)0x20 or > (char)0x7F))
+ {
+ throw new ArgumentException(
+ "OLED text must use the confirmed single-byte display range.",
+ parameterName);
+ }
+
+ int encoded = Encoding.ASCII.GetBytes(value, destination);
+ if (encoded != value.Length)
+ {
+ throw new ArgumentException(
+ "OLED text must encode to exactly one byte per character.",
+ parameterName);
+ }
+ }
+
+ private static byte EncodeFunction(byte function) =>
+ checked((byte)((function << 4) | SoftwareId));
+
+ private static bool IsRuntimeIndex(byte runtimeIndex) =>
+ runtimeIndex is >= 0x02 and < 0xFF;
+
+ private static void RequireRuntimeIndex(byte runtimeIndex)
+ {
+ if (!IsRuntimeIndex(runtimeIndex))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(runtimeIndex),
+ runtimeIndex,
+ "A discovered runtime index must be between 0x02 and 0xFE.");
+ }
+ }
+
+ private static void RequireResponseLength(ReadOnlySpan response)
+ {
+ if (response.Length != VeryLongReportLength)
+ {
+ throw new Rs50OledProtocolException(
+ $"Expected {VeryLongReportLength} response bytes, received " +
+ $"{response.Length}.");
+ }
+ }
+
+ private static void RequireHeader(
+ ReadOnlySpan response,
+ byte featureIndex,
+ byte function)
+ {
+ if (response[0] != VeryLongReportId ||
+ response[1] != DeviceIndex ||
+ response[2] != featureIndex ||
+ response[3] != function)
+ {
+ throw new Rs50OledProtocolException(
+ "The response header does not exactly match its request.");
+ }
+ }
+
+ private static void ThrowIfHidppError(
+ ReadOnlySpan response,
+ byte featureIndex,
+ byte function)
+ {
+ if (response[0] == VeryLongReportId &&
+ response[1] == DeviceIndex &&
+ response[2] == 0xFF &&
+ response[4] == featureIndex &&
+ response[5] == function)
+ {
+ throw new Rs50OledProtocolException(
+ $"The device rejected the OLED request with HID++ error " +
+ $"0x{response[6]:X2}.");
+ }
+ }
+
+ private static void RequireZero(
+ ReadOnlySpan bytes,
+ string field)
+ {
+ if (bytes.IndexOfAnyExcept((byte)0) >= 0)
+ {
+ throw new Rs50OledProtocolException(
+ $"Unexpected nonzero byte in {field}.");
+ }
+ }
+}
diff --git a/LogiDynamicDash/Hidpp/Rs50OledProtocolException.cs b/LogiDynamicDash/Hidpp/Rs50OledProtocolException.cs
new file mode 100644
index 0000000..f3ace2d
--- /dev/null
+++ b/LogiDynamicDash/Hidpp/Rs50OledProtocolException.cs
@@ -0,0 +1,9 @@
+namespace LogiDynamicDash.Hidpp;
+
+internal sealed class Rs50OledProtocolException : Exception
+{
+ internal Rs50OledProtocolException(string message)
+ : base(message)
+ {
+ }
+}
diff --git a/LogiDynamicDash/Hidpp/Rs50OledSession.cs b/LogiDynamicDash/Hidpp/Rs50OledSession.cs
new file mode 100644
index 0000000..e9851ba
--- /dev/null
+++ b/LogiDynamicDash/Hidpp/Rs50OledSession.cs
@@ -0,0 +1,156 @@
+using LogiDynamicDash.Hidpp.Transport;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Hidpp;
+
+internal enum Rs50OledSendResult
+{
+ Transmitted,
+ Unacknowledged,
+ Unchanged,
+ RateLimited
+}
+
+internal interface IRs50OledSession : IDisposable
+{
+ void Open();
+
+ Rs50OledSendResult Send(Rs50OledFrame frame);
+}
+
+///
+/// Owns one strictly bounded OLED exchange. A protocol or transport failure
+/// permanently faults the session; callers must dispose it and explicitly
+/// create a new session rather than retrying a write.
+///
+internal sealed class Rs50OledSession(
+ IRs50OledExchange exchange,
+ TimeProvider? timeProvider = null) : IRs50OledSession
+{
+ private static readonly TimeSpan MinimumTransmissionInterval =
+ TimeSpan.FromMilliseconds(200);
+
+ private readonly TimeProvider clock =
+ timeProvider ?? TimeProvider.System;
+ private readonly object synchronization = new();
+
+ private byte? runtimeIndex;
+ private Rs50OledFrame? lastSubmittedFrame;
+ private long lastTransmissionTimestamp;
+ private bool hasTransmitted;
+ private bool faulted;
+ private bool disposed;
+
+ public void Open()
+ {
+ lock (synchronization)
+ {
+ ThrowIfDisposed();
+ ThrowIfFaulted();
+ if (runtimeIndex is not null)
+ {
+ throw new InvalidOperationException(
+ "The RS50 OLED session is already open.");
+ }
+
+ try
+ {
+ byte[] response =
+ exchange.Exchange(Rs50OledProtocol.CreateDiscovery());
+ runtimeIndex =
+ Rs50OledProtocol.ParseDiscoveryResponse(response);
+ }
+ catch
+ {
+ faulted = true;
+ throw;
+ }
+ }
+ }
+
+ public Rs50OledSendResult Send(Rs50OledFrame frame)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ lock (synchronization)
+ {
+ ThrowIfDisposed();
+ ThrowIfFaulted();
+
+ if (runtimeIndex is not byte featureIndex)
+ {
+ throw new InvalidOperationException(
+ "The RS50 OLED session is not open.");
+ }
+
+ if (frame == lastSubmittedFrame)
+ {
+ return Rs50OledSendResult.Unchanged;
+ }
+
+ long timestamp = clock.GetTimestamp();
+ if (hasTransmitted &&
+ clock.GetElapsedTime(
+ lastTransmissionTimestamp,
+ timestamp) < MinimumTransmissionInterval)
+ {
+ return Rs50OledSendResult.RateLimited;
+ }
+
+ try
+ {
+ Rs50OledTransaction transaction =
+ Rs50OledProtocol.CreateLayout(featureIndex, frame);
+ byte[] response = exchange.Exchange(transaction);
+ Rs50OledProtocol.ParseLayoutAcknowledgement(
+ transaction,
+ response);
+
+ lastSubmittedFrame = frame;
+ lastTransmissionTimestamp = clock.GetTimestamp();
+ hasTransmitted = true;
+ return Rs50OledSendResult.Transmitted;
+ }
+ catch (Rs50OledAcknowledgementTimeoutException)
+ {
+ lastSubmittedFrame = frame;
+ lastTransmissionTimestamp = clock.GetTimestamp();
+ hasTransmitted = true;
+ return Rs50OledSendResult.Unacknowledged;
+ }
+ catch
+ {
+ faulted = true;
+ throw;
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (synchronization)
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+ runtimeIndex = null;
+ lastSubmittedFrame = null;
+ hasTransmitted = false;
+ exchange.Dispose();
+ }
+ }
+
+ private void ThrowIfFaulted()
+ {
+ if (faulted)
+ {
+ throw new InvalidOperationException(
+ "The RS50 OLED session has failed and cannot be reused.");
+ }
+ }
+
+ private void ThrowIfDisposed() =>
+ ObjectDisposedException.ThrowIf(disposed, this);
+}
diff --git a/LogiDynamicDash/Hidpp/Rs50OledTransaction.cs b/LogiDynamicDash/Hidpp/Rs50OledTransaction.cs
new file mode 100644
index 0000000..e8a33b3
--- /dev/null
+++ b/LogiDynamicDash/Hidpp/Rs50OledTransaction.cs
@@ -0,0 +1,52 @@
+namespace LogiDynamicDash.Hidpp;
+
+internal enum Rs50OledTransactionKind
+{
+ DiscoverDisplayFeature,
+ SetLayoutA,
+ SetLayoutB,
+ SetLayoutC,
+ SetLayoutD,
+ SetLayoutE,
+ SetLayoutF,
+ SetLayoutG,
+ SetLayoutH,
+ SetLayoutI,
+ SetLayoutJ
+}
+
+internal sealed class Rs50OledTransaction
+{
+ private readonly byte[] request;
+
+ private Rs50OledTransaction(
+ Rs50OledTransactionKind kind,
+ byte[] request)
+ {
+ Kind = kind;
+ this.request = (byte[])request.Clone();
+ }
+
+ internal Rs50OledTransactionKind Kind { get; }
+
+ internal ReadOnlyMemory Request =>
+ (byte[])request.Clone();
+
+ internal ReadOnlySpan RequestSpan => request;
+
+ internal static Rs50OledTransaction Discovery(byte[] request) =>
+ new(Rs50OledTransactionKind.DiscoverDisplayFeature, request);
+
+ internal static Rs50OledTransaction Layout(
+ Rs50OledTransactionKind kind,
+ byte[] request)
+ {
+ if (kind is < Rs50OledTransactionKind.SetLayoutA or
+ > Rs50OledTransactionKind.SetLayoutJ)
+ {
+ throw new ArgumentOutOfRangeException(nameof(kind));
+ }
+
+ return new Rs50OledTransaction(kind, request);
+ }
+}
diff --git a/LogiDynamicDash/Hidpp/Transport/ConfirmedOledDeviceIdentity.cs b/LogiDynamicDash/Hidpp/Transport/ConfirmedOledDeviceIdentity.cs
new file mode 100644
index 0000000..cfd0982
--- /dev/null
+++ b/LogiDynamicDash/Hidpp/Transport/ConfirmedOledDeviceIdentity.cs
@@ -0,0 +1,15 @@
+namespace LogiDynamicDash.Hidpp.Transport;
+
+///
+/// A device identity may enter the physical transport only after its complete
+/// collection contract has been confirmed. No speculative PRO identity is
+/// included.
+///
+internal sealed record ConfirmedOledDeviceIdentity(
+ string Model,
+ int VendorId,
+ int ProductId)
+{
+ internal static ConfirmedOledDeviceIdentity Rs50 { get; } =
+ new("RS50", 0x046D, 0xC276);
+}
diff --git a/LogiDynamicDash/Hidpp/Transport/HidSharpRs50HidCatalog.cs b/LogiDynamicDash/Hidpp/Transport/HidSharpRs50HidCatalog.cs
new file mode 100644
index 0000000..8cd995c
--- /dev/null
+++ b/LogiDynamicDash/Hidpp/Transport/HidSharpRs50HidCatalog.cs
@@ -0,0 +1,76 @@
+using HidSharp;
+
+namespace LogiDynamicDash.Hidpp.Transport;
+
+internal sealed class HidSharpRs50HidCatalog(
+ ConfirmedOledDeviceIdentity identity) : IRs50HidCatalog
+{
+ public IReadOnlyList Enumerate() =>
+ DeviceList.Local
+ .GetHidDevices(identity.VendorId, identity.ProductId)
+ .OrderBy(
+ device => device.DevicePath,
+ StringComparer.OrdinalIgnoreCase)
+ .Select(
+ device =>
+ (IRs50HidCollection)new HidSharpRs50HidCollection(
+ device))
+ .ToArray();
+
+ private sealed class HidSharpRs50HidCollection(HidDevice device)
+ : IRs50HidCollection
+ {
+ private readonly HidDevice device = device;
+
+ public int VendorId => device.VendorID;
+
+ public int ProductId => device.ProductID;
+
+ public string DevicePath => device.DevicePath;
+
+ public IReadOnlySet Usages { get; } =
+ device
+ .GetReportDescriptor()
+ .DeviceItems
+ .SelectMany(item => item.Usages.GetAllValues())
+ .ToHashSet();
+
+ public int MaximumInputReportLength =>
+ device.GetMaxInputReportLength();
+
+ public int MaximumOutputReportLength =>
+ device.GetMaxOutputReportLength();
+
+ public IRs50HidStream Open()
+ {
+ if (!device.TryOpen(out HidStream stream))
+ {
+ throw new IOException(
+ "The validated RS50 HID++ collection could not be opened.");
+ }
+
+ stream.ReadTimeout = 1000;
+ stream.WriteTimeout = 1000;
+ return new HidSharpRs50HidStream(stream);
+ }
+ }
+
+ private sealed class HidSharpRs50HidStream(HidStream stream)
+ : IRs50HidStream
+ {
+ public int Read(byte[] buffer)
+ {
+ ArgumentNullException.ThrowIfNull(buffer);
+ return stream.Read(buffer, 0, buffer.Length);
+ }
+
+ public void Write(byte[] report)
+ {
+ ArgumentNullException.ThrowIfNull(report);
+ stream.Write(report);
+ }
+
+ public void Dispose() =>
+ stream.Dispose();
+ }
+}
diff --git a/LogiDynamicDash/Hidpp/Transport/IRs50HidCatalog.cs b/LogiDynamicDash/Hidpp/Transport/IRs50HidCatalog.cs
new file mode 100644
index 0000000..2c837f5
--- /dev/null
+++ b/LogiDynamicDash/Hidpp/Transport/IRs50HidCatalog.cs
@@ -0,0 +1,30 @@
+namespace LogiDynamicDash.Hidpp.Transport;
+
+internal interface IRs50HidCatalog
+{
+ IReadOnlyList Enumerate();
+}
+
+internal interface IRs50HidCollection
+{
+ int VendorId { get; }
+
+ int ProductId { get; }
+
+ string DevicePath { get; }
+
+ IReadOnlySet Usages { get; }
+
+ int MaximumInputReportLength { get; }
+
+ int MaximumOutputReportLength { get; }
+
+ IRs50HidStream Open();
+}
+
+internal interface IRs50HidStream : IDisposable
+{
+ int Read(byte[] buffer);
+
+ void Write(byte[] report);
+}
diff --git a/LogiDynamicDash/Hidpp/Transport/Rs50OledAcknowledgementTimeoutException.cs b/LogiDynamicDash/Hidpp/Transport/Rs50OledAcknowledgementTimeoutException.cs
new file mode 100644
index 0000000..590c693
--- /dev/null
+++ b/LogiDynamicDash/Hidpp/Transport/Rs50OledAcknowledgementTimeoutException.cs
@@ -0,0 +1,10 @@
+namespace LogiDynamicDash.Hidpp.Transport;
+
+internal sealed class Rs50OledAcknowledgementTimeoutException(
+ int reportsRead)
+ : IOException(
+ "No matching Display Game Data response was received within " +
+ $"{reportsRead} reports and the bounded response window.")
+{
+ internal int ReportsRead { get; } = reportsRead;
+}
diff --git a/LogiDynamicDash/Hidpp/Transport/Rs50OledDeviceExchange.cs b/LogiDynamicDash/Hidpp/Transport/Rs50OledDeviceExchange.cs
new file mode 100644
index 0000000..2d990d4
--- /dev/null
+++ b/LogiDynamicDash/Hidpp/Transport/Rs50OledDeviceExchange.cs
@@ -0,0 +1,244 @@
+using System.Diagnostics;
+
+namespace LogiDynamicDash.Hidpp.Transport;
+
+///
+/// Strict physical adapter for the two confirmed RS50 HID++ collections.
+/// It accepts only transactions created by .
+///
+internal sealed class Rs50OledDeviceExchange : IRs50OledExchange
+{
+ private const uint ShortCollectionUsage = 0xFF430701;
+ private const uint VeryLongCollectionUsage = 0xFF430704;
+ private const int MaximumReportsPerExchange = 256;
+ private static readonly TimeSpan MaximumResponseWait =
+ TimeSpan.FromMilliseconds(500);
+
+ private readonly IRs50HidStream shortStream;
+ private readonly IRs50HidStream veryLongStream;
+ private readonly object synchronization = new();
+ private bool disposed;
+
+ private Rs50OledDeviceExchange(
+ IRs50HidStream shortStream,
+ IRs50HidStream veryLongStream)
+ {
+ this.shortStream = shortStream;
+ this.veryLongStream = veryLongStream;
+ }
+
+ internal static Rs50OledDeviceExchange Open() =>
+ Open(
+ new HidSharpRs50HidCatalog(
+ ConfirmedOledDeviceIdentity.Rs50),
+ ConfirmedOledDeviceIdentity.Rs50);
+
+ internal static Rs50OledDeviceExchange Open(IRs50HidCatalog catalog)
+ => Open(catalog, ConfirmedOledDeviceIdentity.Rs50);
+
+ private static Rs50OledDeviceExchange Open(
+ IRs50HidCatalog catalog,
+ ConfirmedOledDeviceIdentity identity)
+ {
+ ArgumentNullException.ThrowIfNull(catalog);
+ IReadOnlyList collections =
+ catalog.Enumerate();
+
+ IRs50HidCollection shortCollection = SelectUniqueCollection(
+ collections,
+ identity,
+ pathMarker: "mi_01&col01",
+ ShortCollectionUsage,
+ expectedReportLength: Rs50OledProtocol.ShortReportLength);
+ IRs50HidCollection veryLongCollection = SelectUniqueCollection(
+ collections,
+ identity,
+ pathMarker: "mi_01&col03",
+ VeryLongCollectionUsage,
+ expectedReportLength: Rs50OledProtocol.VeryLongReportLength);
+
+ IRs50HidStream? openedShort = null;
+ try
+ {
+ openedShort = shortCollection.Open();
+ IRs50HidStream openedVeryLong = veryLongCollection.Open();
+ return new Rs50OledDeviceExchange(
+ openedShort,
+ openedVeryLong);
+ }
+ catch
+ {
+ openedShort?.Dispose();
+ throw;
+ }
+ }
+
+ public byte[] Exchange(Rs50OledTransaction transaction)
+ {
+ ArgumentNullException.ThrowIfNull(transaction);
+
+ lock (synchronization)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ byte[] request = transaction.Request.ToArray();
+ ValidateCanonicalTransaction(transaction.Kind, request);
+
+ IRs50HidStream output =
+ transaction.Kind ==
+ Rs50OledTransactionKind.DiscoverDisplayFeature
+ ? shortStream
+ : veryLongStream;
+
+ output.Write(request);
+ return ReadMatchingResponse(transaction.Kind, request);
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (synchronization)
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+ try
+ {
+ shortStream.Dispose();
+ }
+ finally
+ {
+ veryLongStream.Dispose();
+ }
+ }
+ }
+
+ private byte[] ReadMatchingResponse(
+ Rs50OledTransactionKind kind,
+ byte[] request)
+ {
+ long started = Stopwatch.GetTimestamp();
+ int reportsRead = 0;
+ for (int index = 0; index < MaximumReportsPerExchange; index++)
+ {
+ byte[] response =
+ new byte[Rs50OledProtocol.VeryLongReportLength];
+ int bytesRead = veryLongStream.Read(response);
+ reportsRead++;
+ if (bytesRead != response.Length)
+ {
+ throw new IOException(
+ $"Expected {response.Length} HID++ response bytes, " +
+ $"received {bytesRead}.");
+ }
+
+ if (Matches(kind, request, response))
+ {
+ return response;
+ }
+
+ if (Stopwatch.GetElapsedTime(started) >= MaximumResponseWait)
+ {
+ break;
+ }
+ }
+
+ throw new Rs50OledAcknowledgementTimeoutException(reportsRead);
+ }
+
+ private static bool Matches(
+ Rs50OledTransactionKind kind,
+ byte[] request,
+ byte[] response)
+ {
+ if (response[0] != 0x12 || response[1] != 0xFF)
+ {
+ return false;
+ }
+
+ byte expectedFeature =
+ kind == Rs50OledTransactionKind.DiscoverDisplayFeature
+ ? (byte)0
+ : request[2];
+ byte expectedFunction = request[3];
+
+ bool exact =
+ response[2] == expectedFeature &&
+ response[3] == expectedFunction;
+ bool matchingError =
+ response[2] == 0xFF &&
+ response[3] == 0x0A &&
+ response[4] == expectedFeature &&
+ response[5] == expectedFunction;
+
+ return exact || matchingError;
+ }
+
+ private static void ValidateCanonicalTransaction(
+ Rs50OledTransactionKind kind,
+ byte[] request)
+ {
+ if (kind == Rs50OledTransactionKind.DiscoverDisplayFeature)
+ {
+ if (!request.SequenceEqual(
+ Rs50OledProtocol.CreateDiscovery().Request.Span))
+ {
+ throw new InvalidOperationException(
+ "The discovery request is not canonical.");
+ }
+
+ return;
+ }
+
+ int expectedLayout =
+ kind - Rs50OledTransactionKind.SetLayoutA;
+ if (kind is < Rs50OledTransactionKind.SetLayoutA or
+ > Rs50OledTransactionKind.SetLayoutJ ||
+ request.Length != Rs50OledProtocol.VeryLongReportLength ||
+ request[0] != 0x12 ||
+ request[1] != 0xFF ||
+ request[2] is < 0x02 or >= 0xFF ||
+ request[3] != 0x3A ||
+ request[4] != expectedLayout ||
+ request[63] != 0)
+ {
+ throw new InvalidOperationException(
+ "The OLED layout request is not canonical.");
+ }
+ }
+
+ private static IRs50HidCollection SelectUniqueCollection(
+ IReadOnlyList collections,
+ ConfirmedOledDeviceIdentity identity,
+ string pathMarker,
+ uint usage,
+ int expectedReportLength)
+ {
+ IRs50HidCollection[] matches = collections
+ .Where(collection =>
+ collection.VendorId == identity.VendorId &&
+ collection.ProductId == identity.ProductId &&
+ collection.DevicePath.Contains(
+ pathMarker,
+ StringComparison.OrdinalIgnoreCase) &&
+ collection.Usages.Count == 1 &&
+ collection.Usages.Contains(usage) &&
+ collection.MaximumInputReportLength ==
+ expectedReportLength &&
+ collection.MaximumOutputReportLength ==
+ expectedReportLength)
+ .ToArray();
+
+ if (matches.Length != 1)
+ {
+ throw new InvalidOperationException(
+ $"Expected exactly one validated {identity.Model} " +
+ $"{pathMarker} " +
+ $"collection, found {matches.Length}.");
+ }
+
+ return matches[0];
+ }
+}
diff --git a/LogiDynamicDash/LogiDynamicDash.csproj b/LogiDynamicDash/LogiDynamicDash.csproj
index eed76c0..26e404a 100644
--- a/LogiDynamicDash/LogiDynamicDash.csproj
+++ b/LogiDynamicDash/LogiDynamicDash.csproj
@@ -5,9 +5,15 @@
net10.0
enable
enable
+ 0.3.0-alpha
+ 0.3.0.0
+ 0.3.0.0
+ 0.3.0-alpha
+ https://github.com/PeposCJ/LogiDynamicDash
+
diff --git a/LogiDynamicDash/LogiDynamicDashApplication.cs b/LogiDynamicDash/LogiDynamicDashApplication.cs
new file mode 100644
index 0000000..7920112
--- /dev/null
+++ b/LogiDynamicDash/LogiDynamicDashApplication.cs
@@ -0,0 +1,202 @@
+using LogiDynamicDash.Controllers;
+using LogiDynamicDash.Diagnostics;
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Services;
+using System.Runtime.ExceptionServices;
+
+namespace LogiDynamicDash;
+
+internal enum ApplicationLifecycleState
+{
+ Disabled,
+ Opening,
+ Active,
+ Faulted,
+ Stopped
+}
+
+internal sealed class LogiDynamicDashApplication(
+ ITelemetrySource telemetrySource,
+ IApplicationDisplay display,
+ DisplayController controller,
+ TimeProvider? timeProvider = null,
+ IApplicationRuntimeDiagnostics? diagnostics = null)
+{
+ private static readonly TimeSpan RefreshInterval =
+ TimeSpan.FromMilliseconds(200);
+
+ private readonly TimeProvider clock = timeProvider ?? TimeProvider.System;
+ private readonly object renderSynchronization = new();
+ private long lastRefreshTimestamp;
+ private bool hasRefreshed;
+
+ internal ApplicationLifecycleState State { get; private set; } =
+ ApplicationLifecycleState.Disabled;
+
+ internal async Task RunAsync(CancellationToken cancellationToken)
+ {
+ if (State != ApplicationLifecycleState.Disabled)
+ {
+ throw new InvalidOperationException(
+ "The application lifecycle cannot be restarted.");
+ }
+
+ State = ApplicationLifecycleState.Opening;
+ bool initialized = false;
+ try
+ {
+ display.Initialize();
+ initialized = true;
+ State = ApplicationLifecycleState.Active;
+ Render(new TelemetrySnapshot(), "initial");
+
+ await MonitorWithHeartbeatAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Normal bounded-run or Ctrl+C completion.
+ }
+ catch
+ {
+ State = ApplicationLifecycleState.Faulted;
+ throw;
+ }
+ finally
+ {
+ if (initialized)
+ {
+ try
+ {
+ display.Stop();
+ }
+ catch
+ {
+ State = ApplicationLifecycleState.Faulted;
+ throw;
+ }
+ }
+
+ if (State != ApplicationLifecycleState.Faulted)
+ {
+ State = ApplicationLifecycleState.Stopped;
+ }
+
+ diagnostics?.RecordStop(State);
+ }
+ }
+
+ private async Task MonitorWithHeartbeatAsync(
+ CancellationToken cancellationToken)
+ {
+ using CancellationTokenSource linked =
+ CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ Task monitor = telemetrySource.MonitorAsync(
+ HandleTelemetryUpdated,
+ HandleStatusChanged,
+ linked.Token);
+ Task heartbeat = FlushLoopAsync(linked.Token);
+ Task completed = await Task.WhenAny(monitor, heartbeat);
+
+ if (completed == heartbeat)
+ {
+ Exception? heartbeatFailure = null;
+ try
+ {
+ await heartbeat;
+ }
+ catch (Exception exception)
+ {
+ heartbeatFailure = exception;
+ }
+
+ linked.Cancel();
+ try
+ {
+ await monitor;
+ }
+ catch (OperationCanceledException) when (linked.IsCancellationRequested)
+ {
+ // The source observed cancellation after the heartbeat ended.
+ }
+
+ if (heartbeatFailure is not null)
+ {
+ ExceptionDispatchInfo.Capture(heartbeatFailure).Throw();
+ }
+
+ return;
+ }
+
+ try
+ {
+ await monitor;
+ }
+ finally
+ {
+ linked.Cancel();
+ try
+ {
+ await heartbeat;
+ }
+ catch (OperationCanceledException) when (linked.IsCancellationRequested)
+ {
+ // Expected when telemetry monitoring completes first.
+ }
+ }
+ }
+
+ private async Task FlushLoopAsync(CancellationToken cancellationToken)
+ {
+ using PeriodicTimer timer = new(RefreshInterval, clock);
+ while (await timer.WaitForNextTickAsync(cancellationToken))
+ {
+ lock (renderSynchronization)
+ {
+ display.Flush();
+ }
+ }
+ }
+
+ private void HandleTelemetryUpdated(TelemetrySnapshot current)
+ {
+ lock (renderSynchronization)
+ {
+ long now = clock.GetTimestamp();
+ if (hasRefreshed &&
+ clock.GetElapsedTime(
+ lastRefreshTimestamp,
+ now) < RefreshInterval)
+ {
+ return;
+ }
+
+ RenderCore(current, "telemetry");
+ }
+ }
+
+ private void HandleStatusChanged(TelemetrySnapshot current)
+ {
+ lock (renderSynchronization)
+ {
+ RenderCore(current, "status");
+ }
+ }
+
+ private void Render(TelemetrySnapshot current, string trigger)
+ {
+ lock (renderSynchronization)
+ {
+ RenderCore(current, trigger);
+ }
+ }
+
+ private void RenderCore(TelemetrySnapshot current, string trigger)
+ {
+ DisplayMode mode = controller.SelectMode(current);
+ diagnostics?.RecordRender(trigger, current, mode);
+ display.Render(current, mode);
+ lastRefreshTimestamp = clock.GetTimestamp();
+ hasRefreshed = true;
+ }
+}
diff --git a/LogiDynamicDash/Models/DisciplineProfileRecommendations.cs b/LogiDynamicDash/Models/DisciplineProfileRecommendations.cs
new file mode 100644
index 0000000..f118a72
--- /dev/null
+++ b/LogiDynamicDash/Models/DisciplineProfileRecommendations.cs
@@ -0,0 +1,113 @@
+namespace LogiDynamicDash.Models;
+
+internal sealed record DisciplineProfileRecommendation(
+ IRacingDiscipline Discipline,
+ string Summary,
+ Rs50OledConfiguration Configuration);
+
+internal static class DisciplineProfileRecommendations
+{
+ internal static readonly IReadOnlyList
+ SupportedDisciplines =
+ [
+ IRacingDiscipline.SportsCar,
+ IRacingDiscipline.FormulaCar,
+ IRacingDiscipline.Oval,
+ IRacingDiscipline.DirtOval,
+ IRacingDiscipline.DirtRoad
+ ];
+
+ internal static DisciplineProfileRecommendation Create(
+ IRacingDiscipline discipline,
+ SpeedUnit speedUnit)
+ {
+ if (!SupportedDisciplines.Contains(discipline))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(discipline),
+ "A current iRacing category is required.");
+ }
+
+ IReadOnlyDictionary layouts =
+ discipline switch
+ {
+ IRacingDiscipline.SportsCar or
+ IRacingDiscipline.FormulaCar or
+ IRacingDiscipline.DirtRoad =>
+ new Dictionary
+ {
+ [DisplayMode.Normal] = Rs50OledLayout.E,
+ [DisplayMode.BrakeBias] = Rs50OledLayout.H,
+ [DisplayMode.LastLap] = Rs50OledLayout.J,
+ [DisplayMode.ConnectionProblem] = Rs50OledLayout.H
+ },
+ IRacingDiscipline.Oval or
+ IRacingDiscipline.DirtOval =>
+ new Dictionary
+ {
+ [DisplayMode.Normal] = Rs50OledLayout.D,
+ [DisplayMode.BrakeBias] = Rs50OledLayout.H,
+ [DisplayMode.LastLap] = Rs50OledLayout.J,
+ [DisplayMode.ConnectionProblem] = Rs50OledLayout.H
+ },
+ _ => throw new ArgumentOutOfRangeException(nameof(discipline))
+ };
+
+ double maximumRpm = discipline switch
+ {
+ IRacingDiscipline.SportsCar => 8000,
+ IRacingDiscipline.FormulaCar => 12000,
+ IRacingDiscipline.Oval => 9000,
+ IRacingDiscipline.DirtOval => 8500,
+ IRacingDiscipline.DirtRoad => 9000,
+ _ => throw new ArgumentOutOfRangeException(nameof(discipline))
+ };
+ double gaugeMaximumSpeed = (discipline, speedUnit) switch
+ {
+ (IRacingDiscipline.SportsCar, SpeedUnit.KilometersPerHour) => 300,
+ (IRacingDiscipline.SportsCar, SpeedUnit.MilesPerHour) => 190,
+ (IRacingDiscipline.FormulaCar, SpeedUnit.KilometersPerHour) => 350,
+ (IRacingDiscipline.FormulaCar, SpeedUnit.MilesPerHour) => 220,
+ (IRacingDiscipline.Oval, SpeedUnit.KilometersPerHour) => 360,
+ (IRacingDiscipline.Oval, SpeedUnit.MilesPerHour) => 225,
+ (IRacingDiscipline.DirtOval, SpeedUnit.KilometersPerHour) => 180,
+ (IRacingDiscipline.DirtOval, SpeedUnit.MilesPerHour) => 110,
+ (IRacingDiscipline.DirtRoad, SpeedUnit.KilometersPerHour) => 220,
+ (IRacingDiscipline.DirtRoad, SpeedUnit.MilesPerHour) => 140,
+ _ => throw new ArgumentOutOfRangeException(nameof(speedUnit))
+ };
+ string summary = discipline switch
+ {
+ IRacingDiscipline.SportsCar =>
+ "Sports Car: layout E emphasizes RPM, speed, and frequent " +
+ "gear changes. H makes brake-bias adjustments legible; J " +
+ "gives lap time maximum space.",
+ IRacingDiscipline.FormulaCar =>
+ "Formula Car: layout E prioritizes the high-RPM band, gear, " +
+ "and speed. H keeps brake-bias changes clear; J isolates lap " +
+ "time.",
+ IRacingDiscipline.Oval =>
+ "Oval: layout D keeps RPM and speed visible with compact " +
+ "gear/status text. H favors quick setup checks; J keeps lap " +
+ "timing clear.",
+ IRacingDiscipline.DirtOval =>
+ "Dirt Oval: layout D favors a stable gear, RPM, and speed " +
+ "readout while the car is sliding. H and J reserve setup and " +
+ "lap-time pages.",
+ IRacingDiscipline.DirtRoad =>
+ "Dirt Road: layout E emphasizes rapid gear changes, RPM, and " +
+ "speed. H makes brake-bias adjustments legible; J gives lap " +
+ "time maximum space.",
+ _ => throw new ArgumentOutOfRangeException(nameof(discipline))
+ };
+
+ return new DisciplineProfileRecommendation(
+ discipline,
+ summary,
+ new Rs50OledConfiguration(
+ layouts,
+ speedUnit,
+ maximumRpm,
+ gaugeMaximumSpeed));
+ }
+}
diff --git a/LogiDynamicDash/Models/IRacingSessionIdentity.cs b/LogiDynamicDash/Models/IRacingSessionIdentity.cs
new file mode 100644
index 0000000..f870b36
--- /dev/null
+++ b/LogiDynamicDash/Models/IRacingSessionIdentity.cs
@@ -0,0 +1,66 @@
+namespace LogiDynamicDash.Models;
+
+internal enum IRacingDiscipline
+{
+ Unknown,
+ SportsCar,
+ FormulaCar,
+ Oval,
+ DirtOval,
+ DirtRoad,
+ LegacyRoad
+}
+
+internal sealed record CarIdentity(
+ int? CarId,
+ string CarPath,
+ string DisplayName,
+ string ShortName,
+ int? CarClassId,
+ string CarClassShortName,
+ bool IsElectric);
+
+internal sealed record IRacingSessionIdentity(
+ IRacingDiscipline Discipline,
+ string RawCategory,
+ string TrackType,
+ CarIdentity? Car);
+
+internal static class IRacingDisciplineParser
+{
+ internal static IRacingDiscipline Parse(string? category)
+ {
+ string normalized = new(
+ (category ?? string.Empty)
+ .Where(char.IsLetterOrDigit)
+ .Select(char.ToUpperInvariant)
+ .ToArray());
+ return normalized switch
+ {
+ "SPORTSCAR" or "SPORTSCARS" =>
+ IRacingDiscipline.SportsCar,
+ "FORMULA" or "FORMULACAR" or "FORMULACARS" =>
+ IRacingDiscipline.FormulaCar,
+ "OVAL" => IRacingDiscipline.Oval,
+ "DIRTOVAL" => IRacingDiscipline.DirtOval,
+ "DIRTROAD" => IRacingDiscipline.DirtRoad,
+ "ROAD" => IRacingDiscipline.LegacyRoad,
+ _ => IRacingDiscipline.Unknown
+ };
+ }
+}
+
+internal static class IRacingDisciplineDisplay
+{
+ internal static string Name(IRacingDiscipline discipline) =>
+ discipline switch
+ {
+ IRacingDiscipline.SportsCar => "Sports Car",
+ IRacingDiscipline.FormulaCar => "Formula Car",
+ IRacingDiscipline.Oval => "Oval",
+ IRacingDiscipline.DirtOval => "Dirt Oval",
+ IRacingDiscipline.DirtRoad => "Dirt Road",
+ IRacingDiscipline.LegacyRoad => "Legacy Road",
+ _ => "Unknown"
+ };
+}
diff --git a/LogiDynamicDash/Models/Rs50OledConfiguration.cs b/LogiDynamicDash/Models/Rs50OledConfiguration.cs
new file mode 100644
index 0000000..46d23c8
--- /dev/null
+++ b/LogiDynamicDash/Models/Rs50OledConfiguration.cs
@@ -0,0 +1,105 @@
+namespace LogiDynamicDash.Models;
+
+internal enum SpeedUnit
+{
+ KilometersPerHour,
+ MilesPerHour
+}
+
+internal sealed record Rs50OledConfiguration
+{
+ private readonly IReadOnlyDictionary layouts;
+
+ internal Rs50OledConfiguration(
+ Rs50OledLayout layout,
+ SpeedUnit speedUnit = SpeedUnit.KilometersPerHour,
+ double maximumRpm = 8000,
+ double gaugeMaximumSpeed = 300)
+ : this(
+ AllModesForLayout(layout),
+ speedUnit,
+ maximumRpm,
+ gaugeMaximumSpeed)
+ {
+ }
+
+ internal Rs50OledConfiguration(
+ IReadOnlyDictionary layouts,
+ SpeedUnit speedUnit = SpeedUnit.KilometersPerHour,
+ double maximumRpm = 8000,
+ double gaugeMaximumSpeed = 300)
+ {
+ ArgumentNullException.ThrowIfNull(layouts);
+ DisplayMode[] modes = Enum.GetValues();
+ if (layouts.Count != modes.Length ||
+ modes.Any(mode => !layouts.TryGetValue(mode, out _) ||
+ !Enum.IsDefined(layouts[mode])))
+ {
+ throw new ArgumentException(
+ "Every display mode must map to one confirmed layout A-J.",
+ nameof(layouts));
+ }
+
+ if (!Enum.IsDefined(speedUnit))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(speedUnit));
+ }
+
+ if (!double.IsFinite(maximumRpm) || maximumRpm <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(maximumRpm),
+ "Maximum RPM must be finite and greater than zero.");
+ }
+
+ if (!double.IsFinite(gaugeMaximumSpeed) ||
+ gaugeMaximumSpeed <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(gaugeMaximumSpeed),
+ "Gauge maximum speed must be finite and greater than zero.");
+ }
+
+ this.layouts = new Dictionary(layouts);
+ SpeedUnit = speedUnit;
+ MaximumRpm = maximumRpm;
+ GaugeMaximumSpeed = gaugeMaximumSpeed;
+ }
+
+ internal Rs50OledLayout Layout => LayoutFor(DisplayMode.Normal);
+
+ internal Rs50OledLayout LayoutFor(DisplayMode mode)
+ {
+ if (!Enum.IsDefined(mode))
+ {
+ throw new ArgumentOutOfRangeException(nameof(mode));
+ }
+
+ return layouts[mode];
+ }
+
+ internal SpeedUnit SpeedUnit { get; }
+
+ internal double MaximumRpm { get; }
+
+ ///
+ /// Maximum value, in the selected speed unit, represented by a full
+ /// secondary speed indicator.
+ ///
+ internal double GaugeMaximumSpeed { get; }
+
+ private static IReadOnlyDictionary
+ AllModesForLayout(Rs50OledLayout layout)
+ {
+ if (!Enum.IsDefined(layout))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(layout),
+ "Only confirmed layouts A-J are supported.");
+ }
+
+ return Enum.GetValues()
+ .ToDictionary(mode => mode, _ => layout);
+ }
+}
diff --git a/LogiDynamicDash/Models/Rs50OledFrame.cs b/LogiDynamicDash/Models/Rs50OledFrame.cs
new file mode 100644
index 0000000..da5dbed
--- /dev/null
+++ b/LogiDynamicDash/Models/Rs50OledFrame.cs
@@ -0,0 +1,88 @@
+namespace LogiDynamicDash.Models;
+
+internal enum Rs50OledLayout : byte
+{
+ A = 0,
+ B = 1,
+ C = 2,
+ D = 3,
+ E = 4,
+ F = 5,
+ G = 6,
+ H = 7,
+ I = 8,
+ J = 9
+}
+
+internal readonly record struct Rs50GaugeLevel
+{
+ private Rs50GaugeLevel(byte wireValue)
+ {
+ WireValue = wireValue;
+ }
+
+ internal byte WireValue { get; }
+
+ internal static Rs50GaugeLevel FromRatio(double ratio)
+ {
+ if (!double.IsFinite(ratio))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(ratio),
+ "A gauge ratio must be finite.");
+ }
+
+ double clamped = Math.Clamp(ratio, 0d, 1d);
+ byte wireValue = checked((byte)Math.Round(
+ clamped * byte.MaxValue,
+ MidpointRounding.AwayFromZero));
+ return new Rs50GaugeLevel(wireValue);
+ }
+}
+
+internal abstract record Rs50OledFrame(Rs50OledLayout Layout);
+
+internal sealed record Rs50LayoutAFrame()
+ : Rs50OledFrame(Rs50OledLayout.A);
+
+internal sealed record Rs50LayoutBFrame()
+ : Rs50OledFrame(Rs50OledLayout.B);
+
+internal sealed record Rs50LayoutCFrame(Rs50GaugeLevel MainGauge)
+ : Rs50OledFrame(Rs50OledLayout.C);
+
+internal sealed record Rs50LayoutDFrame(
+ Rs50GaugeLevel MainGauge,
+ Rs50GaugeLevel ThinIndicator,
+ string Text)
+ : Rs50OledFrame(Rs50OledLayout.D);
+
+internal sealed record Rs50LayoutEFrame(
+ Rs50GaugeLevel MainGauge,
+ Rs50GaugeLevel ThinIndicator,
+ string LeftText,
+ string RightText)
+ : Rs50OledFrame(Rs50OledLayout.E);
+
+internal sealed record Rs50LayoutFFrame(string LeftText, string RightText)
+ : Rs50OledFrame(Rs50OledLayout.F);
+
+internal sealed record Rs50LayoutGFrame(string LeftText, string RightText)
+ : Rs50OledFrame(Rs50OledLayout.G);
+
+internal sealed record Rs50LayoutHFrame(string TopText, string BottomText)
+ : Rs50OledFrame(Rs50OledLayout.H);
+
+internal sealed record Rs50LayoutIFrame(
+ string Line1,
+ string Line2,
+ string Line3,
+ string Line4)
+ : Rs50OledFrame(Rs50OledLayout.I);
+
+internal sealed record Rs50LayoutJFrame(
+ string Line1,
+ string Line2,
+ string Line3,
+ string Line4)
+ : Rs50OledFrame(Rs50OledLayout.J);
diff --git a/LogiDynamicDash/Models/SessionProfileResolver.cs b/LogiDynamicDash/Models/SessionProfileResolver.cs
new file mode 100644
index 0000000..4336d5a
--- /dev/null
+++ b/LogiDynamicDash/Models/SessionProfileResolver.cs
@@ -0,0 +1,57 @@
+namespace LogiDynamicDash.Models;
+
+internal sealed record IRacingCarProfileKey(
+ int CarId,
+ IRacingDiscipline Discipline);
+
+internal sealed record SessionProfileResolution(
+ IRacingSessionIdentity Identity,
+ IRacingCarProfileKey? CarKey,
+ DisciplineProfileRecommendation? Recommendation,
+ string Explanation)
+{
+ internal bool CanApply => Recommendation is not null;
+}
+
+internal static class SessionProfileResolver
+{
+ internal static SessionProfileResolution Resolve(
+ IRacingSessionIdentity identity,
+ SpeedUnit speedUnit)
+ {
+ ArgumentNullException.ThrowIfNull(identity);
+
+ if (identity.Discipline is
+ IRacingDiscipline.Unknown or IRacingDiscipline.LegacyRoad)
+ {
+ return new SessionProfileResolution(
+ identity,
+ null,
+ null,
+ "The event category is unknown or legacy. Keep the current " +
+ "manual profile.");
+ }
+
+ if (identity.Car?.CarId is not int carId)
+ {
+ return new SessionProfileResolution(
+ identity,
+ null,
+ null,
+ "The driver car has no exact CarID. Keep the current manual " +
+ "profile.");
+ }
+
+ DisciplineProfileRecommendation recommendation =
+ DisciplineProfileRecommendations.Create(
+ identity.Discipline,
+ speedUnit);
+ return new SessionProfileResolution(
+ identity,
+ new IRacingCarProfileKey(carId, identity.Discipline),
+ recommendation,
+ $"Exact CarID {carId} in " +
+ $"{IRacingDisciplineDisplay.Name(identity.Discipline)} maps to " +
+ "the reviewed category profile.");
+ }
+}
diff --git a/LogiDynamicDash/Models/TelemetrySnapshot.cs b/LogiDynamicDash/Models/TelemetrySnapshot.cs
index 9949dfa..18d7dfe 100644
--- a/LogiDynamicDash/Models/TelemetrySnapshot.cs
+++ b/LogiDynamicDash/Models/TelemetrySnapshot.cs
@@ -14,4 +14,19 @@ internal sealed class TelemetrySnapshot
public float? BrakeBiasPercent { get; set; }
public float? LastLapTimeSeconds { get; set; }
-}
\ No newline at end of file
+
+ public IRacingSessionIdentity? SessionIdentity { get; set; }
+
+ internal TelemetrySnapshot Copy() =>
+ new()
+ {
+ ConnectionState = ConnectionState,
+ IsOnTrack = IsOnTrack,
+ Gear = Gear,
+ Rpm = Rpm,
+ SpeedMetersPerSecond = SpeedMetersPerSecond,
+ BrakeBiasPercent = BrakeBiasPercent,
+ LastLapTimeSeconds = LastLapTimeSeconds,
+ SessionIdentity = SessionIdentity
+ };
+}
diff --git a/LogiDynamicDash/Offline/OfflineCommandLine.cs b/LogiDynamicDash/Offline/OfflineCommandLine.cs
new file mode 100644
index 0000000..fbba87f
--- /dev/null
+++ b/LogiDynamicDash/Offline/OfflineCommandLine.cs
@@ -0,0 +1,85 @@
+namespace LogiDynamicDash.Offline;
+
+internal enum OfflineCommandKind
+{
+ PreviewAll,
+ SimulateAll,
+ Replay,
+ RecordTelemetry
+}
+
+internal sealed record OfflineCommand(
+ OfflineCommandKind Kind,
+ string? ConfigurationPath = null,
+ string? TelemetryPath = null,
+ string? OutputPath = null,
+ int? DurationSeconds = null);
+
+internal static class OfflineCommandLine
+{
+ internal static bool TryParse(
+ string[] arguments,
+ out OfflineCommand? command)
+ {
+ ArgumentNullException.ThrowIfNull(arguments);
+ command = null;
+ if (arguments.Length == 5 &&
+ arguments[0] == "--record-telemetry" &&
+ arguments[1] == "--output" &&
+ !string.IsNullOrWhiteSpace(arguments[2]) &&
+ arguments[3] == "--duration-seconds" &&
+ int.TryParse(arguments[4], out int durationSeconds) &&
+ durationSeconds is >= 1 and <= 1800)
+ {
+ command = new OfflineCommand(
+ OfflineCommandKind.RecordTelemetry,
+ OutputPath: arguments[2],
+ DurationSeconds: durationSeconds);
+ return true;
+ }
+
+ if (arguments.Length == 5 &&
+ arguments[0] == "--replay" &&
+ arguments[1] == "--config" &&
+ !string.IsNullOrWhiteSpace(arguments[2]) &&
+ arguments[3] == "--telemetry" &&
+ !string.IsNullOrWhiteSpace(arguments[4]))
+ {
+ command = new OfflineCommand(
+ OfflineCommandKind.Replay,
+ arguments[2],
+ arguments[4]);
+ return true;
+ }
+
+ if (arguments.Length != 3 ||
+ arguments[1] != "--config" ||
+ string.IsNullOrWhiteSpace(arguments[2]))
+ {
+ return false;
+ }
+
+ OfflineCommandKind kind = arguments[0] switch
+ {
+ "--preview-all" => OfflineCommandKind.PreviewAll,
+ "--simulate-all" => OfflineCommandKind.SimulateAll,
+ _ => (OfflineCommandKind)(-1)
+ };
+ if (!Enum.IsDefined(kind))
+ {
+ return false;
+ }
+
+ command = new OfflineCommand(kind, arguments[2]);
+ return true;
+ }
+
+ internal static string Usage =>
+ "Offline commands (never enumerate or open HID devices):\n" +
+ " LogiDynamicDash.exe --preview-all --config \n" +
+ " LogiDynamicDash.exe --simulate-all --config \n" +
+ " LogiDynamicDash.exe --replay --config " +
+ "--telemetry \n" +
+ " LogiDynamicDash.exe --record-telemetry --output " +
+ "--duration-seconds <1-1800>";
+}
diff --git a/LogiDynamicDash/Offline/Rs50OledFrameDescription.cs b/LogiDynamicDash/Offline/Rs50OledFrameDescription.cs
new file mode 100644
index 0000000..b6a8779
--- /dev/null
+++ b/LogiDynamicDash/Offline/Rs50OledFrameDescription.cs
@@ -0,0 +1,43 @@
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Offline;
+
+internal static class Rs50OledFrameDescription
+{
+ internal static string Describe(Rs50OledFrame frame) => frame switch
+ {
+ Rs50LayoutAFrame =>
+ "blank",
+ Rs50LayoutBFrame =>
+ "firmware-test",
+ Rs50LayoutCFrame value =>
+ $"main={value.MainGauge.WireValue}",
+ Rs50LayoutDFrame value =>
+ $"main={value.MainGauge.WireValue} " +
+ $"thin={value.ThinIndicator.WireValue} " +
+ $"text=\"{value.Text}\"",
+ Rs50LayoutEFrame value =>
+ $"main={value.MainGauge.WireValue} " +
+ $"thin={value.ThinIndicator.WireValue} " +
+ $"left=\"{value.LeftText}\" right=\"{value.RightText}\"",
+ Rs50LayoutFFrame value =>
+ $"left=\"{value.LeftText}\" right=\"{value.RightText}\"",
+ Rs50LayoutGFrame value =>
+ $"left=\"{value.LeftText}\" right=\"{value.RightText}\"",
+ Rs50LayoutHFrame value =>
+ $"top=\"{value.TopText}\" bottom=\"{value.BottomText}\"",
+ Rs50LayoutIFrame value =>
+ DescribeFourRows(value.Line1, value.Line2, value.Line3, value.Line4),
+ Rs50LayoutJFrame value =>
+ DescribeFourRows(value.Line1, value.Line2, value.Line3, value.Line4),
+ _ => throw new ArgumentOutOfRangeException(nameof(frame))
+ };
+
+ private static string DescribeFourRows(
+ string line1,
+ string line2,
+ string line3,
+ string line4) =>
+ $"line1=\"{line1}\" line2=\"{line2}\" " +
+ $"line3=\"{line3}\" line4=\"{line4}\"";
+}
diff --git a/LogiDynamicDash/Offline/Rs50OledPreviewRunner.cs b/LogiDynamicDash/Offline/Rs50OledPreviewRunner.cs
new file mode 100644
index 0000000..5fb00f1
--- /dev/null
+++ b/LogiDynamicDash/Offline/Rs50OledPreviewRunner.cs
@@ -0,0 +1,47 @@
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Offline;
+
+internal static class Rs50OledPreviewRunner
+{
+ internal static void RunAll(
+ Rs50OledConfiguration configuration,
+ TextWriter output)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ ArgumentNullException.ThrowIfNull(output);
+
+ TelemetrySnapshot sample = CreateSample();
+ foreach (Rs50OledLayout layout in Enum.GetValues())
+ {
+ Rs50OledConfiguration layoutConfiguration = new(
+ layout,
+ configuration.SpeedUnit,
+ configuration.MaximumRpm,
+ configuration.GaugeMaximumSpeed);
+ Rs50TelemetryFrameFormatter formatter =
+ new(layoutConfiguration);
+
+ output.WriteLine($"LAYOUT {layout}");
+ foreach (DisplayMode mode in Enum.GetValues())
+ {
+ Rs50OledFrame frame = formatter.Format(sample, mode);
+ output.WriteLine(
+ $" {mode}: {Rs50OledFrameDescription.Describe(frame)}");
+ }
+ }
+ }
+
+ private static TelemetrySnapshot CreateSample() =>
+ new()
+ {
+ ConnectionState = "ERROR",
+ IsOnTrack = true,
+ Gear = 3,
+ Rpm = 6500,
+ SpeedMetersPerSecond = 123f / 3.6f,
+ BrakeBiasPercent = 52.3f,
+ LastLapTimeSeconds = 92.481f
+ };
+}
diff --git a/LogiDynamicDash/Offline/Rs50OledSimulationRunner.cs b/LogiDynamicDash/Offline/Rs50OledSimulationRunner.cs
new file mode 100644
index 0000000..e58708e
--- /dev/null
+++ b/LogiDynamicDash/Offline/Rs50OledSimulationRunner.cs
@@ -0,0 +1,155 @@
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Hidpp;
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Offline;
+
+internal sealed record Rs50OledSimulationResult(
+ Rs50OledLayout Layout,
+ int Updates,
+ int Transmitted,
+ int Unchanged,
+ int RateLimited,
+ int DiscoveryTransactions,
+ int LayoutTransactions);
+
+internal static class Rs50OledSimulationRunner
+{
+ private const int UpdateIntervalMilliseconds = 50;
+ private const int SimulationDurationSeconds = 30;
+
+ internal static IReadOnlyList RunAll(
+ Rs50OledConfiguration configuration,
+ TextWriter output)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ ArgumentNullException.ThrowIfNull(output);
+
+ List results = [];
+ foreach (Rs50OledLayout layout in Enum.GetValues())
+ {
+ Rs50OledConfiguration layoutConfiguration = new(
+ layout,
+ configuration.SpeedUnit,
+ configuration.MaximumRpm,
+ configuration.GaugeMaximumSpeed);
+ Rs50OledSimulationResult result =
+ RunOne(layoutConfiguration);
+ results.Add(result);
+ output.WriteLine(
+ $"LAYOUT {layout}: updates={result.Updates} " +
+ $"transmitted={result.Transmitted} " +
+ $"unchanged={result.Unchanged} " +
+ $"rate_limited={result.RateLimited} " +
+ $"discovery={result.DiscoveryTransactions} " +
+ $"layout_transactions={result.LayoutTransactions}");
+ }
+
+ return results;
+ }
+
+ internal static Rs50OledSimulationResult RunOne(
+ Rs50OledConfiguration configuration)
+ {
+ SimulationTimeProvider clock = new();
+ SimulatedRs50OledExchange exchange = new();
+ using Rs50OledSession session = new(exchange, clock);
+ session.Open();
+ Rs50TelemetryFrameFormatter formatter = new(configuration);
+
+ int transmitted = 0;
+ int unchanged = 0;
+ int rateLimited = 0;
+ int updates = 0;
+ int totalSteps =
+ SimulationDurationSeconds * 1000 /
+ UpdateIntervalMilliseconds;
+
+ for (int step = 0; step <= totalSteps; step++)
+ {
+ double seconds =
+ step * UpdateIntervalMilliseconds / 1000d;
+ (TelemetrySnapshot snapshot, DisplayMode mode) =
+ CreateScenario(seconds);
+ Rs50OledFrame frame = formatter.Format(snapshot, mode);
+ Rs50OledSendResult result = session.Send(frame);
+ updates++;
+
+ switch (result)
+ {
+ case Rs50OledSendResult.Transmitted:
+ transmitted++;
+ break;
+ case Rs50OledSendResult.Unchanged:
+ unchanged++;
+ break;
+ case Rs50OledSendResult.RateLimited:
+ rateLimited++;
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(result));
+ }
+
+ clock.Advance(
+ TimeSpan.FromMilliseconds(UpdateIntervalMilliseconds));
+ }
+
+ return new Rs50OledSimulationResult(
+ configuration.Layout,
+ updates,
+ transmitted,
+ unchanged,
+ rateLimited,
+ exchange.DiscoveryCount,
+ exchange.LayoutCount);
+ }
+
+ private static (TelemetrySnapshot Snapshot, DisplayMode Mode)
+ CreateScenario(double seconds)
+ {
+ TelemetrySnapshot snapshot = new();
+ if (seconds < 2)
+ {
+ snapshot.ConnectionState = "WAITING";
+ return (snapshot, DisplayMode.ConnectionProblem);
+ }
+
+ if (seconds >= 28)
+ {
+ snapshot.ConnectionState = "ERROR";
+ return (snapshot, DisplayMode.ConnectionProblem);
+ }
+
+ double drivingSeconds = seconds - 2;
+ double speedKmh = Math.Clamp(drivingSeconds * 12, 0, 240);
+ snapshot.ConnectionState = "CONNECTED";
+ snapshot.IsOnTrack = true;
+ snapshot.SpeedMetersPerSecond = (float)(speedKmh / 3.6);
+ snapshot.Gear = Math.Clamp((int)(speedKmh / 40) + 1, 1, 6);
+ snapshot.Rpm =
+ (float)(2500 + (drivingSeconds * 1700) % 5500);
+ snapshot.BrakeBiasPercent =
+ (float)(52.0 + Math.Sin(drivingSeconds) * 0.5);
+ snapshot.LastLapTimeSeconds = 92.481f;
+
+ DisplayMode mode = seconds switch
+ {
+ >= 10 and < 12 => DisplayMode.BrakeBias,
+ >= 20 and < 23 => DisplayMode.LastLap,
+ _ => DisplayMode.Normal
+ };
+ return (snapshot, mode);
+ }
+
+ private sealed class SimulationTimeProvider : TimeProvider
+ {
+ private long timestamp;
+
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+
+ public override long GetTimestamp() => timestamp;
+
+ internal void Advance(TimeSpan duration) =>
+ timestamp += duration.Ticks;
+ }
+}
diff --git a/LogiDynamicDash/Offline/Rs50TelemetryRecorder.cs b/LogiDynamicDash/Offline/Rs50TelemetryRecorder.cs
new file mode 100644
index 0000000..597c637
--- /dev/null
+++ b/LogiDynamicDash/Offline/Rs50TelemetryRecorder.cs
@@ -0,0 +1,158 @@
+using System.Text.Json;
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Services;
+
+namespace LogiDynamicDash.Offline;
+
+internal sealed class Rs50TelemetryRecorder(
+ ITelemetrySource source,
+ TimeProvider? timeProvider = null)
+{
+ private const int MaximumEvents = 10000;
+ private static readonly TimeSpan UpdateInterval =
+ TimeSpan.FromMilliseconds(200);
+
+ private readonly TimeProvider clock = timeProvider ?? TimeProvider.System;
+ private readonly object synchronization = new();
+ private readonly List events = [];
+ private long started;
+ private long lastUpdate;
+ private bool hasUpdate;
+
+ internal async Task RecordAsync(
+ string outputPath,
+ TimeSpan duration,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
+ if (duration < TimeSpan.FromSeconds(1) ||
+ duration > TimeSpan.FromMinutes(30))
+ {
+ throw new ArgumentOutOfRangeException(nameof(duration));
+ }
+
+ string fullPath = Path.GetFullPath(outputPath);
+ if (File.Exists(fullPath))
+ {
+ throw new IOException(
+ "The telemetry replay output already exists.");
+ }
+
+ string? directory = Path.GetDirectoryName(fullPath);
+ if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
+ {
+ throw new DirectoryNotFoundException(
+ "The telemetry replay output directory does not exist.");
+ }
+
+ started = clock.GetTimestamp();
+ using CancellationTokenSource bounded =
+ CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ bounded.CancelAfter(duration);
+ try
+ {
+ await source.MonitorAsync(
+ snapshot => Record(snapshot, statusChanged: false),
+ snapshot => Record(snapshot, statusChanged: true),
+ bounded.Token);
+ }
+ catch (OperationCanceledException) when (bounded.IsCancellationRequested)
+ {
+ // Expected at the requested recording duration or caller cancel.
+ }
+
+ IReadOnlyList captured;
+ lock (synchronization)
+ {
+ captured = events.ToArray();
+ }
+
+ if (captured.Count == 0)
+ {
+ throw new InvalidOperationException(
+ "No telemetry events were captured.");
+ }
+
+ TelemetryReplayFile.Save(fullPath, captured);
+ }
+
+ private void Record(TelemetrySnapshot snapshot, bool statusChanged)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ lock (synchronization)
+ {
+ long now = clock.GetTimestamp();
+ if (!statusChanged &&
+ hasUpdate &&
+ clock.GetElapsedTime(lastUpdate, now) < UpdateInterval)
+ {
+ return;
+ }
+
+ if (events.Count >= MaximumEvents)
+ {
+ throw new InvalidOperationException(
+ $"Telemetry recording is limited to {MaximumEvents} events.");
+ }
+
+ int milliseconds = checked(
+ (int)Math.Min(
+ clock.GetElapsedTime(started, now).TotalMilliseconds,
+ 86_400_000));
+ events.Add(new TelemetryReplayEvent(
+ milliseconds,
+ statusChanged,
+ snapshot.ConnectionState,
+ snapshot.IsOnTrack,
+ snapshot.Gear,
+ snapshot.Rpm,
+ snapshot.SpeedMetersPerSecond,
+ snapshot.BrakeBiasPercent,
+ snapshot.LastLapTimeSeconds,
+ snapshot.SessionIdentity));
+ if (!statusChanged)
+ {
+ lastUpdate = now;
+ hasUpdate = true;
+ }
+ }
+ }
+}
+
+internal static partial class TelemetryReplayFileExtensions
+{
+ internal static object ToSerializable(TelemetryReplayEvent replayEvent) =>
+ new
+ {
+ replayEvent.AtMilliseconds,
+ replayEvent.StatusChanged,
+ replayEvent.ConnectionState,
+ replayEvent.IsOnTrack,
+ replayEvent.Gear,
+ replayEvent.Rpm,
+ replayEvent.SpeedMetersPerSecond,
+ replayEvent.BrakeBiasPercent,
+ replayEvent.LastLapTimeSeconds,
+ SessionIdentity = replayEvent.SessionIdentity is null
+ ? null
+ : new
+ {
+ Discipline =
+ replayEvent.SessionIdentity.Discipline.ToString(),
+ replayEvent.SessionIdentity.RawCategory,
+ replayEvent.SessionIdentity.TrackType,
+ Car = replayEvent.SessionIdentity.Car is null
+ ? null
+ : new
+ {
+ replayEvent.SessionIdentity.Car.CarId,
+ replayEvent.SessionIdentity.Car.CarPath,
+ replayEvent.SessionIdentity.Car.DisplayName,
+ replayEvent.SessionIdentity.Car.ShortName,
+ replayEvent.SessionIdentity.Car.CarClassId,
+ replayEvent.SessionIdentity.Car.CarClassShortName,
+ replayEvent.SessionIdentity.Car.IsElectric
+ }
+ }
+ };
+}
diff --git a/LogiDynamicDash/Offline/Rs50TelemetryReplayRunner.cs b/LogiDynamicDash/Offline/Rs50TelemetryReplayRunner.cs
new file mode 100644
index 0000000..10849e6
--- /dev/null
+++ b/LogiDynamicDash/Offline/Rs50TelemetryReplayRunner.cs
@@ -0,0 +1,458 @@
+using System.Text.Json;
+using LogiDynamicDash.Controllers;
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Services;
+
+namespace LogiDynamicDash.Offline;
+
+internal sealed record TelemetryReplayEvent(
+ int AtMilliseconds,
+ bool StatusChanged,
+ string ConnectionState,
+ bool? IsOnTrack,
+ int? Gear,
+ float? Rpm,
+ float? SpeedMetersPerSecond,
+ float? BrakeBiasPercent,
+ float? LastLapTimeSeconds,
+ IRacingSessionIdentity? SessionIdentity = null);
+
+internal static class Rs50TelemetryReplayRunner
+{
+ internal static async Task RunAsync(
+ Rs50OledConfiguration configuration,
+ string telemetryPath,
+ TextWriter output)
+ {
+ IReadOnlyList events =
+ TelemetryReplayFile.Load(telemetryPath);
+ ReplayTimeProvider clock = new();
+ ReplayDisplay display = new(configuration, output, clock);
+ LogiDynamicDashApplication application = new(
+ new ReplayTelemetrySource(events, clock),
+ display,
+ new DisplayController(clock),
+ clock);
+
+ await application.RunAsync(CancellationToken.None);
+ output.WriteLine(
+ $"REPLAY COMPLETE events={events.Count} renders={display.RenderCount}");
+ }
+}
+
+internal static class TelemetryReplayFile
+{
+ private const int MaximumFileBytes = 256 * 1024;
+ private static readonly string[] EventPropertiesV1 =
+ [
+ "atMilliseconds",
+ "statusChanged",
+ "connectionState",
+ "isOnTrack",
+ "gear",
+ "rpm",
+ "speedMetersPerSecond",
+ "brakeBiasPercent",
+ "lastLapTimeSeconds"
+ ];
+ private static readonly string[] EventPropertiesV2 =
+ [
+ .. EventPropertiesV1,
+ "sessionIdentity"
+ ];
+ private static readonly string[] IdentityProperties =
+ [
+ "discipline",
+ "rawCategory",
+ "trackType",
+ "car"
+ ];
+ private static readonly string[] CarProperties =
+ [
+ "carId",
+ "carPath",
+ "displayName",
+ "shortName",
+ "carClassId",
+ "carClassShortName",
+ "isElectric"
+ ];
+
+ internal static IReadOnlyList Load(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ FileInfo file = new(path);
+ if (!file.Exists)
+ {
+ throw new FileNotFoundException(
+ "The telemetry replay file was not found.",
+ path);
+ }
+
+ if (file.Length > MaximumFileBytes)
+ {
+ throw new InvalidDataException(
+ $"The telemetry replay exceeds {MaximumFileBytes} bytes.");
+ }
+
+ return Parse(File.ReadAllText(file.FullName));
+ }
+
+ internal static void Save(
+ string path,
+ IReadOnlyList events)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ ArgumentNullException.ThrowIfNull(events);
+ if (events.Count is < 1 or > 10000)
+ {
+ throw new InvalidDataException(
+ "Telemetry replay events must contain 1 through 10000 items.");
+ }
+
+ string json = JsonSerializer.Serialize(
+ new
+ {
+ schemaVersion = 2,
+ events = events.Select(TelemetryReplayFileExtensions.ToSerializable)
+ },
+ new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ });
+ _ = Parse(json);
+ File.WriteAllText(path, json + Environment.NewLine);
+ }
+
+ internal static IReadOnlyList Parse(string json)
+ {
+ ArgumentNullException.ThrowIfNull(json);
+ if (json.Length > MaximumFileBytes)
+ {
+ throw new InvalidDataException(
+ $"The telemetry replay exceeds {MaximumFileBytes} bytes.");
+ }
+
+ using JsonDocument document = JsonDocument.Parse(
+ json,
+ new JsonDocumentOptions
+ {
+ AllowTrailingCommas = false,
+ CommentHandling = JsonCommentHandling.Disallow,
+ MaxDepth = 6
+ });
+ JsonElement root = document.RootElement;
+ RequireObject(root, "Telemetry replay root");
+ Dictionary rootProperties = Unique(root);
+ RequireExact(rootProperties, ["schemaVersion", "events"], "replay");
+ if (!rootProperties["schemaVersion"].TryGetInt32(out int version) ||
+ version is not (1 or 2))
+ {
+ throw new InvalidDataException(
+ "Unsupported telemetry replay schema version.");
+ }
+
+ JsonElement eventArray = rootProperties["events"];
+ if (eventArray.ValueKind != JsonValueKind.Array ||
+ eventArray.GetArrayLength() is < 1 or > 10000)
+ {
+ throw new InvalidDataException(
+ "Telemetry replay events must contain 1 through 10000 items.");
+ }
+
+ List events = [];
+ int previousMilliseconds = -1;
+ foreach (JsonElement element in eventArray.EnumerateArray())
+ {
+ RequireObject(element, "Telemetry replay event");
+ Dictionary values = Unique(element);
+ RequireExact(
+ values,
+ version == 1 ? EventPropertiesV1 : EventPropertiesV2,
+ "telemetry event");
+ int atMilliseconds = RequireInt(values["atMilliseconds"], 0, 86_400_000);
+ if (atMilliseconds < previousMilliseconds)
+ {
+ throw new InvalidDataException(
+ "Telemetry replay events must be ordered by time.");
+ }
+
+ previousMilliseconds = atMilliseconds;
+ string connectionState = RequireString(values["connectionState"]);
+ if (connectionState is not ("WAITING" or "CONNECTED" or "ERROR"))
+ {
+ throw new InvalidDataException(
+ "Replay connectionState must be WAITING, CONNECTED, or ERROR.");
+ }
+
+ events.Add(new TelemetryReplayEvent(
+ atMilliseconds,
+ RequireBoolean(values["statusChanged"]),
+ connectionState,
+ RequireNullableBoolean(values["isOnTrack"]),
+ RequireNullableInt(values["gear"], -1, 99),
+ RequireNullableFloat(values["rpm"], 0, 30000),
+ RequireNullableFloat(values["speedMetersPerSecond"], 0, 200),
+ RequireNullableFloat(values["brakeBiasPercent"], 0, 100),
+ RequireNullableFloat(values["lastLapTimeSeconds"], 0, 6000),
+ version == 2
+ ? ParseIdentity(values["sessionIdentity"])
+ : null));
+ }
+
+ return events;
+ }
+
+ private static Dictionary Unique(JsonElement element)
+ {
+ Dictionary values = new(StringComparer.Ordinal);
+ foreach (JsonProperty property in element.EnumerateObject())
+ {
+ if (!values.TryAdd(property.Name, property.Value))
+ {
+ throw new InvalidDataException(
+ $"Duplicate telemetry replay property '{property.Name}'.");
+ }
+ }
+
+ return values;
+ }
+
+ private static IRacingSessionIdentity? ParseIdentity(JsonElement element)
+ {
+ if (element.ValueKind == JsonValueKind.Null)
+ {
+ return null;
+ }
+
+ RequireObject(element, "Replay sessionIdentity");
+ Dictionary values = Unique(element);
+ RequireExact(values, IdentityProperties, "sessionIdentity");
+ string disciplineName = RequireBoundedString(
+ values["discipline"],
+ 32);
+ if (!Enum.TryParse(
+ disciplineName,
+ ignoreCase: false,
+ out IRacingDiscipline discipline) ||
+ !Enum.IsDefined(discipline))
+ {
+ throw new InvalidDataException(
+ "Replay discipline is not recognized.");
+ }
+
+ return new IRacingSessionIdentity(
+ discipline,
+ RequireBoundedString(values["rawCategory"], 32),
+ RequireBoundedString(values["trackType"], 64),
+ ParseCar(values["car"]));
+ }
+
+ private static CarIdentity? ParseCar(JsonElement element)
+ {
+ if (element.ValueKind == JsonValueKind.Null)
+ {
+ return null;
+ }
+
+ RequireObject(element, "Replay car");
+ Dictionary values = Unique(element);
+ RequireExact(values, CarProperties, "car");
+ return new CarIdentity(
+ RequireNullableInt(values["carId"], 1, int.MaxValue),
+ RequireBoundedString(values["carPath"], 128),
+ RequireBoundedString(values["displayName"], 128),
+ RequireBoundedString(values["shortName"], 64),
+ RequireNullableInt(values["carClassId"], 1, int.MaxValue),
+ RequireBoundedString(values["carClassShortName"], 64),
+ RequireBoolean(values["isElectric"]));
+ }
+
+ private static void RequireExact(
+ IReadOnlyDictionary values,
+ IReadOnlyCollection names,
+ string scope)
+ {
+ if (values.Count != names.Count ||
+ values.Keys.Any(name => !names.Contains(name)) ||
+ names.Any(name => !values.ContainsKey(name)))
+ {
+ throw new InvalidDataException(
+ $"The {scope} contains missing or unknown properties.");
+ }
+ }
+
+ private static void RequireObject(JsonElement element, string scope)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ throw new InvalidDataException($"{scope} must be an object.");
+ }
+ }
+
+ private static string RequireString(JsonElement element) =>
+ element.ValueKind == JsonValueKind.String
+ ? element.GetString()!
+ : throw new InvalidDataException("Replay value must be text.");
+
+ private static string RequireBoundedString(
+ JsonElement element,
+ int maximumLength)
+ {
+ string value = RequireString(element);
+ if (value.Length > maximumLength)
+ {
+ throw new InvalidDataException("Replay text is too long.");
+ }
+
+ return value;
+ }
+
+ private static bool RequireBoolean(JsonElement element) =>
+ element.ValueKind is JsonValueKind.True or JsonValueKind.False
+ ? element.GetBoolean()
+ : throw new InvalidDataException("Replay value must be boolean.");
+
+ private static bool? RequireNullableBoolean(JsonElement element) =>
+ element.ValueKind == JsonValueKind.Null
+ ? null
+ : RequireBoolean(element);
+
+ private static int RequireInt(JsonElement element, int minimum, int maximum)
+ {
+ if (!element.TryGetInt32(out int value) ||
+ value < minimum ||
+ value > maximum)
+ {
+ throw new InvalidDataException("Replay integer is out of range.");
+ }
+
+ return value;
+ }
+
+ private static int? RequireNullableInt(
+ JsonElement element,
+ int minimum,
+ int maximum) =>
+ element.ValueKind == JsonValueKind.Null
+ ? null
+ : RequireInt(element, minimum, maximum);
+
+ private static float? RequireNullableFloat(
+ JsonElement element,
+ float minimum,
+ float maximum)
+ {
+ if (element.ValueKind == JsonValueKind.Null)
+ {
+ return null;
+ }
+
+ if (!element.TryGetSingle(out float value) ||
+ !float.IsFinite(value) ||
+ value < minimum ||
+ value > maximum)
+ {
+ throw new InvalidDataException("Replay number is out of range.");
+ }
+
+ return value;
+ }
+}
+
+internal sealed class ReplayTelemetrySource(
+ IReadOnlyList events,
+ ReplayTimeProvider clock) : ITelemetrySource
+{
+ public Task MonitorAsync(
+ Action onTelemetryUpdated,
+ Action onStatusChanged,
+ CancellationToken cancellationToken)
+ {
+ foreach (TelemetryReplayEvent replayEvent in events)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ clock.AdvanceTo(TimeSpan.FromMilliseconds(replayEvent.AtMilliseconds));
+ TelemetrySnapshot snapshot = new()
+ {
+ ConnectionState = replayEvent.ConnectionState,
+ IsOnTrack = replayEvent.IsOnTrack,
+ Gear = replayEvent.Gear,
+ Rpm = replayEvent.Rpm,
+ SpeedMetersPerSecond = replayEvent.SpeedMetersPerSecond,
+ BrakeBiasPercent = replayEvent.BrakeBiasPercent,
+ LastLapTimeSeconds = replayEvent.LastLapTimeSeconds,
+ SessionIdentity = replayEvent.SessionIdentity
+ };
+ if (replayEvent.StatusChanged)
+ {
+ onStatusChanged(snapshot);
+ }
+ else
+ {
+ onTelemetryUpdated(snapshot);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+}
+
+internal sealed class ReplayTimeProvider : TimeProvider
+{
+ private long timestamp;
+ private readonly DateTimeOffset epoch =
+ new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+
+ public override long GetTimestamp() => timestamp;
+
+ public override DateTimeOffset GetUtcNow() =>
+ epoch.AddTicks(timestamp);
+
+ internal void AdvanceTo(TimeSpan elapsed)
+ {
+ if (elapsed.Ticks < timestamp)
+ {
+ throw new InvalidOperationException(
+ "Replay time cannot move backwards.");
+ }
+
+ timestamp = elapsed.Ticks;
+ }
+}
+
+internal sealed class ReplayDisplay(
+ Rs50OledConfiguration configuration,
+ TextWriter output,
+ ReplayTimeProvider clock) : IApplicationDisplay
+{
+ private readonly Rs50TelemetryFrameFormatter formatter = new(configuration);
+ private bool active;
+
+ internal int RenderCount { get; private set; }
+
+ public void Initialize() => active = true;
+
+ public void Render(TelemetrySnapshot snapshot, DisplayMode mode)
+ {
+ if (!active)
+ {
+ throw new InvalidOperationException("Replay display is not active.");
+ }
+
+ Rs50OledFrame frame = formatter.Format(snapshot, mode);
+ long elapsedMilliseconds =
+ clock.GetElapsedTime(0).Ticks / TimeSpan.TicksPerMillisecond;
+ output.WriteLine(
+ $"{elapsedMilliseconds,8}ms mode={mode} " +
+ $"layout={configuration.LayoutFor(mode)} " +
+ Rs50OledFrameDescription.Describe(frame));
+ RenderCount++;
+ }
+
+ public void Stop() => active = false;
+}
diff --git a/LogiDynamicDash/Offline/SimulatedRs50OledExchange.cs b/LogiDynamicDash/Offline/SimulatedRs50OledExchange.cs
new file mode 100644
index 0000000..553fffd
--- /dev/null
+++ b/LogiDynamicDash/Offline/SimulatedRs50OledExchange.cs
@@ -0,0 +1,41 @@
+using LogiDynamicDash.Hidpp;
+
+namespace LogiDynamicDash.Offline;
+
+internal sealed class SimulatedRs50OledExchange : IRs50OledExchange
+{
+ internal int DiscoveryCount { get; private set; }
+
+ internal int LayoutCount { get; private set; }
+
+ internal bool Disposed { get; private set; }
+
+ public byte[] Exchange(Rs50OledTransaction transaction)
+ {
+ ArgumentNullException.ThrowIfNull(transaction);
+ ObjectDisposedException.ThrowIf(Disposed, this);
+
+ byte[] response = new byte[Rs50OledProtocol.VeryLongReportLength];
+ response[0] = 0x12;
+ response[1] = 0xFF;
+ response[3] = transaction.Request.Span[3];
+
+ if (transaction.Kind ==
+ Rs50OledTransactionKind.DiscoverDisplayFeature)
+ {
+ DiscoveryCount++;
+ response[2] = 0;
+ response[4] = 0x12;
+ }
+ else
+ {
+ LayoutCount++;
+ response[2] = transaction.Request.Span[2];
+ }
+
+ return response;
+ }
+
+ public void Dispose() =>
+ Disposed = true;
+}
diff --git a/LogiDynamicDash/Program.cs b/LogiDynamicDash/Program.cs
index ba009ef..f6422a6 100644
--- a/LogiDynamicDash/Program.cs
+++ b/LogiDynamicDash/Program.cs
@@ -1,7 +1,9 @@
-using System.Diagnostics;
+using LogiDynamicDash.Configuration;
using LogiDynamicDash.Controllers;
-using LogiDynamicDash.Displays;
+using LogiDynamicDash.Diagnostics;
using LogiDynamicDash.Models;
+using LogiDynamicDash.Offline;
+using LogiDynamicDash.Runtime;
using LogiDynamicDash.Services;
using SVappsLAB.iRacingTelemetrySDK;
@@ -17,28 +19,53 @@ namespace LogiDynamicDash;
])]
internal class Program
{
- private static readonly Stopwatch RefreshTimer =
- Stopwatch.StartNew();
-
- private static readonly TelemetrySnapshot Snapshot =
- new();
-
- private static readonly ConsoleDashboard Dashboard =
- new();
-
- private static readonly DisplayController Controller =
- new();
+ private static async Task Main(string[] arguments)
+ {
+ if (Rs50ProductionRunOptions.TryParse(
+ arguments,
+ out Rs50ProductionRunOptions? production))
+ {
+ return await RunProductionAsync(production!);
+ }
- private static readonly IRacingTelemetryService
- TelemetryService = new();
+ if (OfflineCommandLine.TryParse(
+ arguments,
+ out OfflineCommand? offlineCommand))
+ {
+ return await RunOfflineAsync(offlineCommand!);
+ }
- private static async Task Main()
- {
- Dashboard.Initialize();
- RenderCurrentDisplay(Snapshot);
+ ApplicationDisplaySelection? selection;
+ try
+ {
+ if (!ApplicationDisplayFactory.TryCreate(
+ arguments,
+ out selection))
+ {
+ Console.Error.WriteLine(OfflineCommandLine.Usage);
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(Rs50StationaryTrialOptions.Usage);
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(Rs50LowSpeedTrialOptions.Usage);
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(Rs50DrivingTrialOptions.Usage);
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(Rs50ProductionRunOptions.Usage);
+ return 2;
+ }
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(
+ $"Configuration rejected: {exception.Message}");
+ return 2;
+ }
- using var cancellationSource =
- new CancellationTokenSource();
+ using CancellationTokenSource cancellationSource = new();
+ if (selection!.HardwareTrialDuration is TimeSpan trialDuration)
+ {
+ cancellationSource.CancelAfter(trialDuration);
+ }
Console.CancelKeyPress += (_, eventArgs) =>
{
@@ -46,50 +73,142 @@ private static async Task Main()
cancellationSource.Cancel();
};
+ using IApplicationRuntimeDiagnostics? diagnostics =
+ selection.UsesPhysicalHardware
+ ? SanitizedApplicationRuntimeDiagnostics.CreateLocal()
+ : null;
+ LogiDynamicDashApplication application = new(
+ new IRacingTelemetryService(),
+ selection.Display,
+ new DisplayController(),
+ diagnostics: diagnostics);
+ try
+ {
+ await application.RunAsync(cancellationSource.Token);
+ return 0;
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(
+ $"LogiDynamicDash stopped safely: {exception.Message}");
+ return 1;
+ }
+ }
+
+ private static async Task RunProductionAsync(
+ Rs50ProductionRunOptions options)
+ {
+ using CancellationTokenSource cancellationSource = new();
+ ConsoleCancelEventHandler handler = (_, eventArgs) =>
+ {
+ eventArgs.Cancel = true;
+ cancellationSource.Cancel();
+ };
+ Console.CancelKeyPress += handler;
try
{
- await TelemetryService.MonitorAsync(
- Snapshot,
- HandleTelemetryUpdated,
- HandleStatusChanged,
+ DashboardRuntimeSettings settings = new(
+ Rs50OledConfigurationFile.Load(options.ConfigurationPath),
+ options.ProfileDirectory,
+ options.AutomaticProfiles,
+ options.LastLapDisplaySeconds);
+ DashboardRuntime runtime = new();
+ DashboardRuntimeStatus? previous = null;
+ await runtime.RunAsync(
+ settings,
+ status =>
+ {
+ if (status != previous)
+ {
+ Console.WriteLine(
+ $"OLED={status.Oled}; " +
+ $"iRacing={status.Telemetry}; " +
+ $"Car={status.Car}; " +
+ $"Category=" +
+ $"{IRacingDisciplineDisplay.Name(
+ status.Discipline)}; " +
+ status.Message);
+ previous = status;
+ }
+ },
cancellationSource.Token);
+ return 0;
}
- catch (OperationCanceledException)
+ catch (Exception exception)
{
- // Expected when the user presses Ctrl+C.
+ Console.Error.WriteLine(
+ $"LogiDynamicDash stopped safely: {exception.Message}");
+ return 1;
}
finally
{
- Dashboard.Stop();
+ Console.CancelKeyPress -= handler;
}
}
- private static void HandleTelemetryUpdated(
- TelemetrySnapshot snapshot)
+ private static async Task RunOfflineAsync(OfflineCommand command)
{
- if (RefreshTimer.ElapsedMilliseconds < 100)
+ try
{
- return;
- }
+ if (command.Kind == OfflineCommandKind.RecordTelemetry)
+ {
+ Rs50TelemetryRecorder recorder = new(
+ new IRacingTelemetryService());
+ using CancellationTokenSource recordingCancellation = new();
+ ConsoleCancelEventHandler cancelHandler = (_, eventArgs) =>
+ {
+ eventArgs.Cancel = true;
+ recordingCancellation.Cancel();
+ };
+ Console.CancelKeyPress += cancelHandler;
+ try
+ {
+ await recorder.RecordAsync(
+ command.OutputPath!,
+ TimeSpan.FromSeconds(command.DurationSeconds!.Value),
+ recordingCancellation.Token);
+ }
+ finally
+ {
+ Console.CancelKeyPress -= cancelHandler;
+ }
- RenderCurrentDisplay(snapshot);
- RefreshTimer.Restart();
- }
+ Console.WriteLine(
+ $"Telemetry replay saved to '{command.OutputPath}'.");
+ return 0;
+ }
- private static void HandleStatusChanged(
- TelemetrySnapshot snapshot)
- {
- RenderCurrentDisplay(snapshot);
- }
+ Rs50OledConfiguration configuration =
+ Rs50OledConfigurationFile.Load(command.ConfigurationPath!);
+ switch (command.Kind)
+ {
+ case OfflineCommandKind.PreviewAll:
+ Rs50OledPreviewRunner.RunAll(
+ configuration,
+ Console.Out);
+ break;
+ case OfflineCommandKind.SimulateAll:
+ Rs50OledSimulationRunner.RunAll(
+ configuration,
+ Console.Out);
+ break;
+ case OfflineCommandKind.Replay:
+ await Rs50TelemetryReplayRunner.RunAsync(
+ configuration,
+ command.TelemetryPath!,
+ Console.Out);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(command));
+ }
- private static void RenderCurrentDisplay(
- TelemetrySnapshot snapshot)
- {
- DisplayMode mode =
- Controller.SelectMode(snapshot);
-
- Dashboard.Render(
- snapshot,
- mode);
+ return 0;
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(
+ $"Offline command failed: {exception.Message}");
+ return 1;
+ }
}
-}
\ No newline at end of file
+}
diff --git a/LogiDynamicDash/Properties/AssemblyInfo.cs b/LogiDynamicDash/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..4ee9942
--- /dev/null
+++ b/LogiDynamicDash/Properties/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("LogiDynamicDash.Tests")]
+[assembly: InternalsVisibleTo("LogiDynamicDash.Configurator")]
diff --git a/LogiDynamicDash/Runtime/DashboardRuntime.cs b/LogiDynamicDash/Runtime/DashboardRuntime.cs
new file mode 100644
index 0000000..449f99c
--- /dev/null
+++ b/LogiDynamicDash/Runtime/DashboardRuntime.cs
@@ -0,0 +1,109 @@
+using LogiDynamicDash.Configuration;
+using LogiDynamicDash.Controllers;
+using LogiDynamicDash.Diagnostics;
+using LogiDynamicDash.Displays;
+using LogiDynamicDash.Models;
+using LogiDynamicDash.Services;
+
+namespace LogiDynamicDash.Runtime;
+
+internal sealed class DashboardRuntime(
+ Func? sessionFactory = null,
+ Func? telemetryFactory = null)
+{
+ private readonly Func createSession =
+ sessionFactory ?? Rs50OledSessionFactory.OpenPhysicalWithLocalDiagnostics;
+ private readonly Func createTelemetry =
+ telemetryFactory ?? (() => new IRacingTelemetryService());
+
+ internal async Task RunAsync(
+ DashboardRuntimeSettings settings,
+ Action statusChanged,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ ArgumentNullException.ThrowIfNull(statusChanged);
+
+ DashboardRuntimeStatus current = DashboardRuntimeStatus.Initial;
+ object statusSynchronization = new();
+
+ void Update(
+ DashboardOledState? oled = null,
+ TelemetrySnapshot? telemetry = null,
+ string? message = null)
+ {
+ lock (statusSynchronization)
+ {
+ string telemetryState =
+ telemetry?.ConnectionState ?? current.Telemetry;
+ IRacingSessionIdentity? identity =
+ telemetry?.SessionIdentity;
+ current = current with
+ {
+ Oled = oled ?? current.Oled,
+ Telemetry = telemetryState,
+ Car = identity?.Car?.DisplayName ??
+ identity?.Car?.ShortName ??
+ current.Car,
+ CarId = identity?.Car?.CarId ?? current.CarId,
+ Discipline = identity?.Discipline ?? current.Discipline,
+ Message = message ?? Message(
+ oled ?? current.Oled,
+ telemetryState)
+ };
+ statusChanged(current);
+ }
+ }
+
+ statusChanged(current);
+ Rs50ProfileStore profiles = new(settings.ProfileDirectory);
+ AutomaticRs50TelemetryFrameFormatter formatter = new(
+ settings.FallbackConfiguration,
+ profiles,
+ settings.AutomaticProfiles);
+ RecoveringRs50OledDisplaySink oledDisplay = new(
+ createSession,
+ formatter,
+ oled => Update(oled: oled));
+ DashboardStatusDisplay telemetryDisplay = new(
+ telemetry => Update(telemetry: telemetry));
+ CompositeApplicationDisplay display = new(
+ telemetryDisplay,
+ oledDisplay);
+ using IApplicationRuntimeDiagnostics diagnostics =
+ SanitizedApplicationRuntimeDiagnostics.CreateLocal();
+ LogiDynamicDashApplication application = new(
+ createTelemetry(),
+ display,
+ new DisplayController(
+ lastLapDuration: settings.LastLapDuration),
+ diagnostics: diagnostics);
+
+ try
+ {
+ await application.RunAsync(cancellationToken);
+ }
+ finally
+ {
+ Update(
+ oled: DashboardOledState.Stopped,
+ message: "Dashboard stopped.");
+ }
+ }
+
+ private static string Message(
+ DashboardOledState oled,
+ string telemetry) =>
+ (oled, telemetry.ToUpperInvariant()) switch
+ {
+ (DashboardOledState.Connected, "CONNECTED") =>
+ "OLED and iRacing telemetry are active.",
+ (DashboardOledState.Connected, _) =>
+ "OLED connected; waiting for iRacing telemetry.",
+ (DashboardOledState.Reconnecting, _) =>
+ "RS50 disconnected; reconnecting automatically.",
+ (DashboardOledState.Stopped, _) =>
+ "Dashboard stopped.",
+ _ => "Waiting for the validated RS50 OLED."
+ };
+}
diff --git a/LogiDynamicDash/Runtime/DashboardRuntimeSettings.cs b/LogiDynamicDash/Runtime/DashboardRuntimeSettings.cs
new file mode 100644
index 0000000..264f5ca
--- /dev/null
+++ b/LogiDynamicDash/Runtime/DashboardRuntimeSettings.cs
@@ -0,0 +1,26 @@
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Runtime;
+
+internal sealed record DashboardRuntimeSettings(
+ Rs50OledConfiguration FallbackConfiguration,
+ string ProfileDirectory,
+ bool AutomaticProfiles = true,
+ double LastLapDisplaySeconds = 5)
+{
+ internal TimeSpan LastLapDuration
+ {
+ get
+ {
+ if (!double.IsFinite(LastLapDisplaySeconds) ||
+ LastLapDisplaySeconds is < 1 or > 15)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(LastLapDisplaySeconds),
+ "Last-lap duration must be between 1 and 15 seconds.");
+ }
+
+ return TimeSpan.FromSeconds(LastLapDisplaySeconds);
+ }
+ }
+}
diff --git a/LogiDynamicDash/Runtime/DashboardRuntimeStatus.cs b/LogiDynamicDash/Runtime/DashboardRuntimeStatus.cs
new file mode 100644
index 0000000..6d41187
--- /dev/null
+++ b/LogiDynamicDash/Runtime/DashboardRuntimeStatus.cs
@@ -0,0 +1,29 @@
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Runtime;
+
+internal enum DashboardOledState
+{
+ Waiting,
+ Connected,
+ Reconnecting,
+ Stopped
+}
+
+internal sealed record DashboardRuntimeStatus(
+ DashboardOledState Oled,
+ string Telemetry,
+ string Car,
+ int? CarId,
+ IRacingDiscipline Discipline,
+ string Message)
+{
+ internal static DashboardRuntimeStatus Initial { get; } =
+ new(
+ DashboardOledState.Waiting,
+ "WAITING",
+ "Unknown car",
+ null,
+ IRacingDiscipline.Unknown,
+ "Waiting for RS50 and iRacing.");
+}
diff --git a/LogiDynamicDash/Services/IRacingSessionIdentityResolver.cs b/LogiDynamicDash/Services/IRacingSessionIdentityResolver.cs
new file mode 100644
index 0000000..f20b978
--- /dev/null
+++ b/LogiDynamicDash/Services/IRacingSessionIdentityResolver.cs
@@ -0,0 +1,46 @@
+using LogiDynamicDash.Models;
+using SVappsLAB.iRacingTelemetrySDK;
+
+namespace LogiDynamicDash.Services;
+
+internal static class IRacingSessionIdentityResolver
+{
+ internal static IRacingSessionIdentity Resolve(
+ TelemetrySessionInfo session)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ string rawCategory = Clean(session.WeekendInfo?.Category, 32);
+ string trackType = Clean(session.WeekendInfo?.TrackType, 64);
+ DriverInfo? driverInfo = session.DriverInfo;
+ Driver? driver = driverInfo?.Drivers?.FirstOrDefault(
+ candidate => candidate.CarIdx == driverInfo.DriverCarIdx);
+
+ CarIdentity? car = driver is null
+ ? null
+ : new CarIdentity(
+ PositiveOrNull(driver.CarID),
+ Clean(driver.CarPath, 128),
+ Clean(driver.CarScreenName, 128),
+ Clean(driver.CarScreenNameShort, 64),
+ PositiveOrNull(driver.CarClassID),
+ Clean(driver.CarClassShortName, 64),
+ driver.CarIsElectric != 0);
+
+ return new IRacingSessionIdentity(
+ IRacingDisciplineParser.Parse(rawCategory),
+ rawCategory,
+ trackType,
+ car);
+ }
+
+ private static int? PositiveOrNull(int value) =>
+ value > 0 ? value : null;
+
+ private static string Clean(string? value, int maximumLength)
+ {
+ string cleaned = (value ?? string.Empty).Trim();
+ return cleaned.Length <= maximumLength
+ ? cleaned
+ : cleaned[..maximumLength];
+ }
+}
diff --git a/LogiDynamicDash/Services/IRacingTelemetryService.cs b/LogiDynamicDash/Services/IRacingTelemetryService.cs
index 9b7ce48..5e5451d 100644
--- a/LogiDynamicDash/Services/IRacingTelemetryService.cs
+++ b/LogiDynamicDash/Services/IRacingTelemetryService.cs
@@ -4,10 +4,9 @@
namespace LogiDynamicDash.Services;
-internal sealed class IRacingTelemetryService
+internal sealed class IRacingTelemetryService : ITelemetrySource
{
public async Task MonitorAsync(
- TelemetrySnapshot snapshot,
Action onTelemetryUpdated,
Action onStatusChanged,
CancellationToken cancellationToken)
@@ -16,15 +15,20 @@ public async Task MonitorAsync(
TelemetryClient.Create(
NullLogger.Instance);
+ TelemetrySnapshot latest = new();
+ object synchronization = new();
var handlers =
new TelemetryHandlers
{
OnConnectStateChanged = state =>
{
- snapshot.ConnectionState =
- state
- .ToString()
- .ToUpperInvariant();
+ TelemetrySnapshot snapshot;
+ lock (synchronization)
+ {
+ latest.ConnectionState =
+ state.ToString().ToUpperInvariant();
+ snapshot = latest.Copy();
+ }
onStatusChanged(snapshot);
@@ -33,33 +37,46 @@ public async Task MonitorAsync(
OnTelemetryUpdate = data =>
{
- snapshot.IsOnTrack =
- data.IsOnTrackCar;
+ TelemetrySnapshot snapshot;
+ lock (synchronization)
+ {
+ latest.IsOnTrack = data.IsOnTrackCar;
+ latest.Gear = data.Gear;
+ latest.Rpm = data.RPM;
+ latest.SpeedMetersPerSecond = data.Speed;
+ latest.BrakeBiasPercent = data.dcBrakeBias;
+ latest.LastLapTimeSeconds = data.LapLastLapTime;
+ snapshot = latest.Copy();
+ }
- snapshot.Gear =
- data.Gear;
-
- snapshot.Rpm =
- data.RPM;
-
- snapshot.SpeedMetersPerSecond =
- data.Speed;
+ onTelemetryUpdated(snapshot);
- snapshot.BrakeBiasPercent =
- data.dcBrakeBias;
+ return Task.CompletedTask;
+ },
- snapshot.LastLapTimeSeconds =
- data.LapLastLapTime;
+ OnSessionInfoUpdate = session =>
+ {
+ TelemetrySnapshot snapshot;
+ lock (synchronization)
+ {
+ latest.SessionIdentity =
+ IRacingSessionIdentityResolver.Resolve(session);
+ snapshot = latest.Copy();
+ }
- onTelemetryUpdated(snapshot);
+ onStatusChanged(snapshot);
return Task.CompletedTask;
},
OnError = _ =>
{
- snapshot.ConnectionState =
- "ERROR";
+ TelemetrySnapshot snapshot;
+ lock (synchronization)
+ {
+ latest.ConnectionState = "ERROR";
+ snapshot = latest.Copy();
+ }
onStatusChanged(snapshot);
@@ -71,4 +88,4 @@ await client.Monitor(
handlers,
cancellationToken);
}
-}
\ No newline at end of file
+}
diff --git a/LogiDynamicDash/Services/ITelemetrySource.cs b/LogiDynamicDash/Services/ITelemetrySource.cs
new file mode 100644
index 0000000..bfc4f1b
--- /dev/null
+++ b/LogiDynamicDash/Services/ITelemetrySource.cs
@@ -0,0 +1,11 @@
+using LogiDynamicDash.Models;
+
+namespace LogiDynamicDash.Services;
+
+internal interface ITelemetrySource
+{
+ Task MonitorAsync(
+ Action onTelemetryUpdated,
+ Action onStatusChanged,
+ CancellationToken cancellationToken);
+}
diff --git a/LogiDynamicDash/docs/GETTING_STARTED.md b/LogiDynamicDash/docs/GETTING_STARTED.md
new file mode 100644
index 0000000..7bb80cf
--- /dev/null
+++ b/LogiDynamicDash/docs/GETTING_STARTED.md
@@ -0,0 +1,52 @@
+# Getting Started
+
+## Requirements
+
+- 64-bit Windows
+- Logitech RS50 with the OLED home screen set to `Dynamic`
+- iRacing
+- G HUB closed while LogiDynamicDash owns the validated OLED collections
+- .NET 10 Desktop Runtime for the framework-dependent package
+
+## Start the dashboard
+
+1. Open `LogiDynamicDash.Configurator.exe`.
+2. Select KMH or MPH.
+3. Keep automatic profiles enabled for the recommended first run.
+4. Set how many seconds `LAST LAP` should remain visible.
+5. Select **Start dashboard**.
+6. Open or join an iRacing session.
+
+The status panel reports the OLED connection, iRacing telemetry, detected car,
+and official category. The runtime waits if either the wheel or iRacing is not
+available. If the RS50 sleeps or disconnects, it retries the exact validated
+OLED interface every two seconds. Select **Stop** before opening G HUB.
+
+## Profiles
+
+Automatic selection uses this order:
+
+1. exact CarID profile;
+2. category profile;
+3. reviewed built-in category recommendation;
+4. the visible fallback configuration.
+
+Use **Save for category** to customize Sports Car, Formula Car, Oval, Dirt
+Oval, or Dirt Road. **Save for this car (Pro preview)** exercises the planned
+per-car override model after a live CarID is detected or after inspecting a
+schema 2 replay. The alpha contains no billing or entitlement enforcement.
+
+Profiles and active settings are stored under:
+
+```text
+%LOCALAPPDATA%\LogiDynamicDash
+```
+
+## Safe shutdown
+
+Select **Stop** and wait for `Dashboard stopped`. Stopping disposes both OLED
+HID streams. The wheel firmware may retain the last frame briefly before its
+normal Dynamic/Test fallback returns.
+
+LogiDynamicDash does not send force-feedback, steering, LED, feature-report,
+firmware, or bootloader commands.
diff --git a/LogiDynamicDash/docs/OFFLINE_FAULT_MATRIX.md b/LogiDynamicDash/docs/OFFLINE_FAULT_MATRIX.md
new file mode 100644
index 0000000..dce6119
--- /dev/null
+++ b/LogiDynamicDash/docs/OFFLINE_FAULT_MATRIX.md
@@ -0,0 +1,35 @@
+# Offline Fault Matrix
+
+This matrix records deterministic production tests. It does not replace the
+postponed physical validation stages.
+
+| Fault or pressure case | Expected behavior | Automated evidence |
+|---|---|---|
+| Missing or duplicate HID collection | Reject before opening a usable exchange | `Rs50OledDeviceExchangeTests` |
+| Wrong usage or report length | Reject exact collection contract | `Rs50OledDeviceExchangeTests` |
+| Short read | Fail the transaction; never retry its write | `Rs50OledDeviceExchangeTests` |
+| Sixteen unrelated responses | Stop bounded read loop after one write | `Rs50OledDeviceExchangeTests` |
+| Discovery transport failure | Permanently fault session | `Rs50OledSessionTests` |
+| Invalid layout acknowledgement | Permanently fault session | `Rs50OledSessionTests` |
+| Changed frame inside 200 ms | Queue latest ordinary frame | `Rs50OledFrameSchedulerTests` |
+| Disconnect frame inside 200 ms | Preserve critical frame ahead of ordinary telemetry | `Rs50OledFrameSchedulerTests` |
+| No telemetry after a rate-limited frame | Flush from independent 200 ms heartbeat | `LogiDynamicDashApplicationTests`, `Rs50OledFrameSchedulerTests` |
+| Heartbeat/display flush failure | Cancel telemetry source, fault, and dispose | `LogiDynamicDashApplicationTests` |
+| Moving, missing, negative, or non-finite on-track speed | Fault sink before OLED send | `Rs50OledDisplaySinkTests` |
+| Display send failure | Enter Faulted; reject retry and reopen | `Rs50OledDisplaySinkTests` |
+| Corrupt, duplicate, unknown, oversized, or out-of-range configuration | Reject before physical session construction | `Rs50OledConfigurationFileTests` |
+| Corrupt, ambiguous, backward-time, or non-finite replay | Reject offline input | `TelemetryReplayTests` |
+| Diagnostic storage write failure | Propagate safe failure; never mask original transport failure | `SanitizedRs50OledDiagnosticsTests` |
+| Requested cancellation | Stop and dispose cleanly | `LogiDynamicDashApplicationTests` |
+| Concurrent telemetry/status callbacks | Serialize display rendering | `LogiDynamicDashApplicationTests` |
+| Concurrent mutable source state | Emit copied snapshots to consumers | `IRacingTelemetryService`, application tests |
+| Track type conflicts with official event category | Keep `WeekendInfo.Category` authoritative; never guess from track | `IRacingSessionIdentityTests` |
+| Unknown/legacy category or missing driver row | Preserve metadata, require manual fallback, and never invent a car | `IRacingSessionIdentityTests`, `DisciplineProfileRecommendationTests` |
+| Replay identity is unknown, malformed, or ambiguous | Reject schema 2 input while retaining schema 1 compatibility | `TelemetryReplayTests` |
+| One million virtual submissions | Remain single-consumer without a pending leak | `Rs50OledFrameSchedulerTests` |
+| Six virtual hours at 20 Hz | Preserve typed formatting without failure | `VirtualEnduranceTests` |
+| Accidental layout output change | Fail reviewed A-J golden output | `GoldenPreviewTests` |
+
+There is deliberately no automatic reopen or reconnect test because production
+does not implement either behavior. Recovery after a fault requires disposal
+and a new explicitly armed process.
diff --git a/LogiDynamicDash/docs/OLED_DISPLAY_DESIGN.md b/LogiDynamicDash/docs/OLED_DISPLAY_DESIGN.md
index 8bca656..8408e84 100644
--- a/LogiDynamicDash/docs/OLED_DISPLAY_DESIGN.md
+++ b/LogiDynamicDash/docs/OLED_DISPLAY_DESIGN.md
@@ -4,6 +4,36 @@ This document defines the intended behavior of the Dynamic OLED display in LogiD
The goal is not to show every available telemetry value. The display should present only information that can be understood with a quick glance while driving.
+## Confirmed hardware contract
+
+Controlled RS50 interoperability research established that Dynamic mode uses
+public HID++ feature `0x8130`, discovered through the Root feature at runtime.
+The feature is a firmware renderer with ten fixed layouts A-J, not a host
+framebuffer.
+
+The production encoder supports only the confirmed layout fields:
+
+| Layout | Host-controlled fields | Intended production use |
+|---|---|---|
+| A | None | Blank frame |
+| B | None | Firmware Test graphic |
+| C | One normalized gauge | RPM or progress |
+| D | Two normalized indicators and one 11-character text | Label/value plus indicators |
+| E | Two normalized indicators, 7-character left text, 3-character right text | Speed, gear, RPM, and secondary indicator |
+| F | 1-character left and 3-character right text | Large right-side value |
+| G | 1-character left and 3-character right text | Large left-side value |
+| H | 21-character top and 10-character bottom text | Two-row status page |
+| I | Text limits 19/10/19/10 | Four-row mixed-alignment page |
+| J | Text limits 19/10/19/10 | Four-row centered page |
+
+Layout E's visual text order is the reverse of its wire-field order. The
+production model names the fields by their visual positions and performs that
+permutation internally.
+
+The confirmed interface does not provide arbitrary pixels, custom fonts, font
+sizes, coordinates, images, Unicode, color, or partial updates. Layout
+selection determines the built-in graphics, typography, and alignment.
+
## Design principles
- Keep the normal driving screen simple.
@@ -216,13 +246,18 @@ Different types of racing may require different normal screens.
Potential profiles:
-- Road
+- Sports Car
+- Formula Car
- Oval
-- Formula
+- Dirt Oval
+- Dirt Road
- Endurance
- Custom
-### Road and formula
+The first five names match current iRacing license/event categories.
+Endurance and Custom are profile variants, not inferred iRacing categories.
+
+### Sports Car and Formula Car
Likely priorities:
@@ -243,6 +278,13 @@ Likely priorities:
Gear may be less important during long periods in the same gear.
+### Dirt Oval and Dirt Road
+
+Dirt Oval starts from the compact Oval presentation at a lower speed scale.
+Dirt Road starts from the shift-focused Sports Car presentation. Both remain
+separate categories so future slip, launch, and surface-specific information
+can be introduced without heuristic reclassification.
+
### Endurance
Likely priorities:
@@ -279,29 +321,30 @@ Values such as connection state and on-track state may remain visible in the con
## First implementation scope
-The first OLED implementation should aim for:
+The production formatter now implements:
-1. Normal gear and speed screen
-2. Temporary brake-bias screen
-3. Temporary last-lap screen
-4. ABS intervention indicator, if a reliable signal is confirmed
-5. Disconnection alert
+1. Normal gear and speed screens across layouts D-J
+2. RPM and speed gauges where the selected layout exposes them
+3. Temporary brake-bias screens
+4. Temporary last-lap screens
+5. Disconnection states
6. Configurable km/h or mph
+7. Configurable RPM and speed gauge scales
+
+ABS intervention remains out of scope until a reliable telemetry signal is
+confirmed. The application must not infer or label ABS activity from
+unvalidated data.
-## Open technical questions
+## Remaining technical questions
-The following must be verified before implementing pixel-perfect layouts:
+The following remain open for production integration:
-- Exact OLED resolution
-- Supported image or text format
-- Display refresh rate
-- Maximum safe update frequency
-- Whether partial screen updates are supported
-- Whether G HUB must be running
-- How Dynamic mode receives display data
-- Whether Logitech provides a public or partner SDK
-- Differences between Logitech PRO and RS50
-- Behavior when another game or application controls the display
-- Whether the display supports inverted regions or only complete frames
+- Differences between Logitech PRO and RS50 behavior
+- Safe ownership and reconnect behavior across sleep or USB renumbering
+- User configuration for choosing layouts and telemetry mappings
+- Long-duration coexistence while driving
+- Fallback behavior when another application controls Dynamic mode
-These questions should be answered through official documentation, SDK access, controlled testing, or protocol research.
\ No newline at end of file
+The 5 Hz shared-HID++ stationary telemetry path has been independently
+validated with normal FFB and LEDs. Moving-car validation is postponed and
+must use a separately reviewed bounded stage before any full-lap claim.
diff --git a/LogiDynamicDash/docs/PHYSICAL_PRODUCT_TEST_MATRIX.md b/LogiDynamicDash/docs/PHYSICAL_PRODUCT_TEST_MATRIX.md
new file mode 100644
index 0000000..2dd7bfe
--- /dev/null
+++ b/LogiDynamicDash/docs/PHYSICAL_PRODUCT_TEST_MATRIX.md
@@ -0,0 +1,61 @@
+# Physical Product Test Matrix
+
+This is the release-validation matrix for the `0.3.0-alpha` daily-use
+runtime. It records expected coverage, not experimental protocol research.
+Raw USB captures and historical discovery notes remain on the research branch.
+
+## Preconditions
+
+- Use the packaged build from the candidate commit.
+- Set the RS50 home screen to `Dynamic`.
+- Close G HUB before selecting **Start dashboard**.
+- Confirm steering, pedals, buttons, FFB, and rev LEDs are normal before and
+ after every session.
+- USBPcap is not required unless a new protocol regression appears.
+
+## Core lifecycle
+
+| Scenario | Expected result |
+|---|---|
+| Start before RS50 is awake | GUI waits; no crash |
+| Wake or reconnect RS50 | OLED connects within the retry window |
+| Start before iRacing | OLED connects and GUI waits for telemetry |
+| Enter and leave the car | Telemetry resumes without restarting the app |
+| Change iRacing session | Car and category update automatically |
+| Stop from GUI | HID streams close and status becomes stopped |
+| Open G HUB after Stop | G HUB can reclaim the wheel normally |
+
+## Driving coverage
+
+Run at least one normal session for each available category:
+
+| Category | Primary checks |
+|---|---|
+| Sports Car | speed, gear, RPM, brake bias, last lap |
+| Formula Car | high-RPM scaling, gear, speed, last lap |
+| Oval | stable compact layout, speed, gear, last lap |
+| Dirt Oval | readability while steering rapidly, reconnect |
+| Dirt Road | rapid gear changes, RPM, speed, last lap |
+
+For every category confirm:
+
+- the large bar follows RPM;
+- the small bar follows speed;
+- KMH or MPH matches the selected setting;
+- `LAST LAP` appears for the configured duration;
+- the detected car and category are correct;
+- no unexpected steering, torque, FFB, LED, input, or connection behavior.
+
+## Endurance and recovery
+
+- Run one 30-minute session with ordinary driving.
+- Allow the wheel to sleep, then wake it and confirm automatic recovery.
+- Disconnect and reconnect the wheel once while stationary.
+- Move from pits to track and back without restarting LogiDynamicDash.
+- Close one iRacing session and start another with a different category.
+
+## Compatibility statement
+
+Passing this matrix validates the tested RS50 hardware and firmware only.
+Logitech Pro Wheel support remains unverified until tested on physical
+hardware.
diff --git a/LogiDynamicDash/docs/PRODUCT_AND_MONETIZATION_PLAN.md b/LogiDynamicDash/docs/PRODUCT_AND_MONETIZATION_PLAN.md
new file mode 100644
index 0000000..e15b97a
--- /dev/null
+++ b/LogiDynamicDash/docs/PRODUCT_AND_MONETIZATION_PLAN.md
@@ -0,0 +1,313 @@
+# Product and Monetization Plan
+
+## Product Position
+
+LogiDynamicDash should make the confirmed RS50 Dynamic OLED useful without
+requiring users to understand HID++, report layouts, or telemetry internals.
+The product promise is:
+
+> Install, choose a racing discipline, preview the result, and get a safe,
+> readable in-wheel dashboard that adapts to the current driving context.
+
+RS50 is the only confirmed device. PRO remains a future compatibility target
+until its identity and collection contract are physically verified.
+
+The OLED exposes ten firmware-rendered layouts rather than an arbitrary
+framebuffer. Product language must therefore say "layout and data
+customization," not custom fonts, unrestricted graphics, or pixel drawing.
+
+## Current GUI Milestone
+
+The `LogiDynamicDash.Configurator` Windows application:
+
+- applies reviewed Sports Car, Formula Car, Oval, Dirt Oval, and Dirt Road
+ recommendations;
+- selects a layout for Normal, Brake Bias, Last Lap, and Connection Problem;
+- edits speed unit, maximum RPM, and gauge maximum speed;
+- renders a typed semantic preview;
+- inspects schema 2 telemetry replays and displays the detected car, class,
+ category, track context, and profile decision;
+- enables a recommendation only when both current category and exact `CarID`
+ are present, then requires an explicit apply action;
+- opens strict schema v1/v2 files and saves schema v2;
+- starts and stops the daily-use runtime only after an explicit click;
+- reports OLED, telemetry, car, and category status;
+- waits for missing hardware and reconnects the exact validated OLED
+ interface after sleep or disconnection;
+- automatically selects exact CarID, category override, or reviewed category
+ defaults in that order;
+- saves one free category profile and exposes the planned per-car override as
+ an unenforced Pro preview.
+
+The GUI does not manage licenses, sign users in, touch FFB or LEDs, expose raw
+HID, or run hardware access before **Start dashboard** is selected.
+
+## Discipline Recommendations
+
+### Sports Car
+
+| Mode | Default | Rationale |
+|---|---:|---|
+| Normal | E | RPM gauge, speed indicator, prominent speed, and gear support frequent shifts and variable corner speeds. |
+| Brake Bias | H | Two large rows make a temporary setup change easy to verify. |
+| Last Lap | J | Four centered rows give lap identity and time maximum clarity. |
+| Connection Problem | H | A large two-row warning is harder to confuse with live telemetry. |
+
+Initial scales: 8,000 RPM and 300 km/h or 190 mph.
+
+### Formula Car
+
+Formula Car uses the same E/H/J/H layout mapping as Sports Car because gear,
+RPM, and speed dominate the normal page. Its initial scale is 12,000 RPM and
+350 km/h or 220 mph to avoid clipping common formula-car ranges.
+
+### Oval
+
+| Mode | Default | Rationale |
+|---|---:|---|
+| Normal | D | Keeps RPM and speed indicators visible while using compact text for gear/status; gear changes are less frequent than in Sports Car or Formula Car. |
+| Brake Bias | H | Makes an adjustment readable without a dense race page. |
+| Last Lap | J | Lap time is central to pace and tire-run evaluation. |
+| Connection Problem | H | Uses the same unmistakable warning page as every other category. |
+
+Initial scales: 9,000 RPM and 360 km/h or 225 mph. Short-track and stock-car
+profiles will eventually override these values per car.
+
+### Dirt Oval
+
+Dirt Oval uses D/H/J/H with initial scales of 8,500 RPM and 180 km/h or
+110 mph. It remains distinct from paved Oval so future dirt-specific data does
+not require guessing from track names.
+
+### Dirt Road
+
+Dirt Road uses E/H/J/H with initial scales of 9,000 RPM and 220 km/h or
+140 mph. Frequent shifts and variable speeds make the shift-focused normal
+layout the safer starting point.
+
+All scales remain editable and will eventually support exact per-car
+overrides.
+
+### Identity and Automatic Selection
+
+iRacing's current official categories are `SportsCar`, `FormulaCar`, `Oval`,
+`DirtOval`, and `DirtRoad`. The former `Road` license is retained only as a
+legacy input and must not silently select a current profile.
+
+The identity priority is:
+
+1. `WeekendInfo.Category` is the authoritative event category;
+2. the driver's row selected by `DriverInfo.DriverCarIdx` supplies `CarID`;
+3. `CarPath`, full/short name, `CarClassID`, class name, and electric flag
+ provide human-readable and migration context;
+4. `TrackType` is diagnostic context only and never overrides the category;
+5. unknown category or missing car identity fails closed to a manual profile.
+
+This matters because event classification can differ from what the track name
+or geometry suggests. A road-course week in another discipline must follow
+the category emitted for that event.
+
+Session metadata capture, normalization, and automatic activation are
+implemented after passing the physical production gate. The fallback policy
+is:
+
+1. read simulator-provided category and driver-car metadata;
+2. normalize only recognized official values;
+3. select the matching profile only when confidence is exact;
+4. retain the user's last manual choice for `Unknown`;
+5. show both detected category and car identity in the GUI and local
+ diagnostic;
+6. allow a per-car override.
+
+The application must never infer category from speed, steering, track name,
+track type, car-name keywords, or other heuristics that could switch the OLED
+while driving.
+
+Official taxonomy references:
+
+- iRacing license classes:
+ https://support.iracing.com/support/solutions/articles/31000133459
+- 2024 Road split into Sports Car and Formula Car:
+ https://support.iracing.com/support/solutions/articles/31000172516-road-license-type-split
+
+## Recommended Free Scope
+
+Free must be a complete and safe product, not a demo:
+
+- confirmed RS50 support and all compatibility/safety fixes;
+- live gear, speed, RPM, brake-bias, last-lap, and connection states;
+- curated profiles for all five current iRacing categories;
+- exact automatic category selection once physically verified;
+- KMH/MPH and gauge scale controls;
+- one active profile per current iRacing category;
+- all firmware layouts A-J;
+- offline preview, telemetry recording, and replay;
+- local JSON import/export;
+- configurator, updates, documentation, and community support;
+- no account required for local operation.
+
+Safety controls, device fixes, data portability, and the ability to use the
+OLED should never be paywalled.
+
+## Recommended Paid Scope
+
+Paid value should come from depth, convenience, and profile management:
+
+- unlimited named profiles and per-car overrides;
+- custom assignment of supported telemetry fields to typed text slots;
+- conditional rules such as qualifying/race, pit limiter, fuel warning, or
+ timed temporary pages;
+- advanced thresholds and alerts;
+- profile duplication, comparison, version history, and backup;
+- community profile library and signed profile distribution;
+- multi-simulator profile synchronization;
+- advanced replay analysis and configuration recommendations;
+- priority support and early access to newly confirmed devices;
+- commercial venue licensing and managed multi-seat deployment.
+
+### Pro automatic car tuning
+
+The free runtime should continue to detect the official iRacing category and
+apply the safe built-in category profile. This is core usability, not a paid
+luxury.
+
+Pro may add exact-CarID automation:
+
+- activate a saved per-car profile automatically;
+- use simulator-provided redline or shift-light metadata as the preferred
+ maximum-RPM source when it is available and valid;
+- learn observed peak RPM only as a fallback, with validation and headroom;
+- learn peak speed over complete representative laps, round it upward with a
+ safety margin, persist it by CarID, and apply it on the next session;
+- never move RPM or speed gauge limits continuously during a live lap;
+- show the detected source and value and allow the user to override or reset
+ it.
+
+Category defaults remain the fallback when exact metadata or sufficient
+driving history is unavailable. Car-name guessing, a single short run, and
+unvalidated telemetry spikes must never overwrite a profile.
+
+Custom field assignment must remain constrained by each confirmed layout's
+field lengths and semantic types. Paid status cannot unlock unknown HID
+functions, raw reports, arbitrary graphics, firmware access, or unsafe rates.
+
+## What Not to Put Behind the Paywall
+
+- OLED connectivity and basic telemetry;
+- exact category/car detection and safe automatic selection;
+- layouts A-J themselves;
+- stationary/movement safety gates;
+- bug fixes and new confirmed device compatibility;
+- local configuration export;
+- privacy controls and diagnostic access;
+- recovery from a paid-plan downgrade.
+
+This boundary keeps free users safe and useful while making Pro attractive to
+racers who manage many cars and want deep automation.
+
+## Open-Source Licensing Reality
+
+The repository is MIT licensed. Anyone may use, modify, sublicense, or sell
+the published source while retaining the license notice. A hard paywall inside
+the same public source can therefore be removed by a fork.
+
+Recommended business model:
+
+- keep the protocol, safety layer, standard dashboard, and local configuration
+ open;
+- sell signed convenience builds, automatic updates, advanced profile tools,
+ hosted synchronization/library features, and priority support;
+- keep licensing/account code in a separately reviewed commercial service or
+ distribution layer if the project chooses that direction;
+- avoid DRM inside the HID transport or safety-critical path.
+
+Do not implement billing until physical production validation and free-product
+retention demonstrate real demand.
+
+## Pricing Hypothesis, Not a Decision
+
+Current sim-racing products validate both approaches:
+
+- RaceLab offers a free tier, a €4.90 monthly Pro plan, annual pricing, a
+ high-priced lifetime option, and commercial seats.
+- Lovely Sim Racing advertises a free start with membership from €1/month.
+- SimHub sells licenses and a separate paid Motion add-on, demonstrating that
+ hardware-adjacent advanced capability can be a one-time purchase.
+
+Recommended experiment after beta:
+
+- Free: complete core described above;
+- Pro Individual: low one-time early-adopter license, including one year of
+ updates;
+- optional annual renewal for new Pro features and hosted services;
+- separate commercial per-seat license;
+- no subscription requirement for basic local OLED operation.
+
+Pricing should be tested with a waitlist and survey before payment code.
+
+Official market references:
+
+- RaceLab plans: https://racelab.app/?anchor=membership
+- Lovely Sim Racing membership positioning: https://store.lsr.gg/
+- SimHub licensing terms: https://www.simhubdash.com/terms-and-conditions/
+- SimHub paid Motion add-on: https://www.simhubdash.com/simhub-motion-addon-licence/
+
+## Release Plan
+
+### 0.2 Technical Preview
+
+- typed RS50 protocol and bounded hardware route;
+- offline preview, simulation, recorder, and replay;
+- hardware-free configurator;
+- manual recommendations for all five current iRacing categories;
+- exact session category and driver-car identity capture;
+- stationary production gate passed on the physical RS50.
+
+### 0.3 Hardware Beta
+
+- successful stationary production gate;
+- successful low-speed and continuous physical moving validation;
+- daily-use GUI start/stop and automatic reconnection;
+- automatic five-category selection with `Unknown`/legacy fallback;
+- free category profile persistence;
+- per-car override matching as an unenforced Pro preview;
+- unsigned, integrity-manifested Windows alpha package;
+- surface detected category/car identity in diagnostics and GUI;
+- local crash/fault reporting with explicit opt-in.
+
+### 0.4 Distribution Beta
+
+- installer and signed release candidate;
+- accessible onboarding and startup integration;
+- profile duplication and management UX;
+- user research on customization demand.
+
+### 1.0 Free
+
+- stable RS50 live use;
+- complete defaults for all five current iRacing categories;
+- documented PRO status;
+- updater and migration guarantees;
+- accessible GUI and onboarding.
+
+### Pro Launch
+
+Only after 1.0 stability:
+
+- per-car profiles and conditional rules;
+- advanced typed-field editor;
+- licensing and entitlement service outside the safety path;
+- commercial deployment option.
+
+## Next Feature Decisions
+
+Before implementing automatic profiles or Pro entitlements, decide:
+
+1. whether the commercial distribution will remain MIT or use an open-core
+ split for new commercial components;
+2. whether Pro is one-time, subscription, or one-time plus update renewal;
+3. which simulator follows iRacing with an equally trustworthy category and
+ car identity contract;
+4. whether community profiles require hosted accounts;
+5. whether user telemetry always remains local by default;
+6. which supported telemetry fields are safe and legible in every layout.
diff --git a/LogiDynamicDash/docs/RS50_OLED_PRODUCTION_ARCHITECTURE.md b/LogiDynamicDash/docs/RS50_OLED_PRODUCTION_ARCHITECTURE.md
new file mode 100644
index 0000000..ea7f6a2
--- /dev/null
+++ b/LogiDynamicDash/docs/RS50_OLED_PRODUCTION_ARCHITECTURE.md
@@ -0,0 +1,258 @@
+# RS50 OLED Production Architecture
+
+## Status
+
+The production implementation is complete through the offline integration
+gate. It is independently written from the confirmed interoperability
+specification and does not merge or copy the research branch history.
+
+Completed offline components:
+
+- typed firmware-rendered layouts A-J;
+- Root discovery of public HID++ feature `0x8130`;
+- exact request and acknowledgement validation;
+- strict RS50 MI_01 COL01/COL03 collection selection;
+- bounded multiplexed-ACK scanning without request retries;
+- nonfatal missing layout acknowledgements with explicit diagnostics;
+- fail-closed session lifecycle;
+- identical-frame suppression, 5 Hz change limit, and latest-frame scheduler;
+- km/h and mph telemetry formatting for all layouts;
+- independent per-mode layout selection;
+- injectable application orchestration and deterministic telemetry replay;
+- copied telemetry snapshots and a 200 ms display heartbeat;
+- bounded hardware-free telemetry recording;
+- hardware-free Windows configuration GUI with all five current iRacing
+ category recommendations;
+- exact iRacing event-category and driver-car identity capture;
+- explicit Disabled/Opening/Active/Faulted/Stopped lifecycle without reconnect;
+- console plus optional OLED display composition;
+- strict persistent JSON configuration;
+- offline A-J preview and end-to-end virtual telemetry simulation;
+- sanitized JSONL diagnostics without report bytes or device identity;
+- exact command-line arming contracts;
+- automatic cancellation for stationary and Build L trials;
+- per-route speed-envelope rejection before the next frame is transmitted.
+
+After correcting the initial redirected-console failure, the separately
+authorized stationary production gate passed on the physical RS50 on
+2026-07-30. Live iRacing telemetry displayed `0 KMH`, neutral, and a graphical
+gauge; acknowledgements, shutdown, FFB, LEDs, controls, and connection were
+normal. The complete result is documented in
+The physical validation evidence remains archived on the dedicated research
+branch rather than shipping with the product source.
+
+## Offline Validation
+
+The current production branch passes:
+
+- 164 unit and integration tests, including one million scheduler submissions;
+- Release build with warnings treated as errors;
+- `dotnet format --verify-no-changes`;
+- `git diff --check`;
+- the production-surface audit in
+ `scripts/Test-Rs50OledProductionSurface.ps1`;
+- direct and transitive NuGet vulnerability audit with no known vulnerable
+ package reported by the configured sources;
+- invalid-command smoke test, which exits with code 2 before constructing a
+ session.
+
+The deterministic 30-second simulation processes 601 updates for each layout.
+Static layouts A/B transmit once; the dynamic layouts transmit between 85 and
+132 acknowledged frames, remaining below the theoretical 151-frame 5 Hz
+ceiling. Separate stress tests cover 20,000 virtual changed frames, one
+million serialized scheduler submissions, and six virtual hours at 20 Hz.
+
+The same build, test, format, surface-audit, and vulnerability steps run in
+the Windows GitHub Actions workflow for pushes and pull requests. After those
+checks pass, CI publishes framework-dependent and self-contained `win-x64`
+artifacts. Each includes example configuration, replay scenarios, notices,
+an SPDX 2.3 SBOM, SHA-256 manifest, and successful packaged-executable smoke
+tests. CI does not sign or release either artifact.
+
+## Data Flow
+
+```text
+iRacing telemetry + authoritative session category/car identity
+ |
+ v
+ITelemetrySource -> application lifecycle and 200 ms refresh coordinator
+ |
+ v
+DisplayController (normal / brake bias / last lap / connection)
+ |
+ v
+Rs50TelemetryFrameFormatter (typed Layout A-J frame)
+ |
+ v
+Rs50OledFrameScheduler (single consumer / latest pending / critical priority)
+ |
+ v
+Rs50OledSession (deduplicate / 5 Hz / fail closed)
+ |
+ v
+Rs50OledProtocol (closed 0x8130 request)
+ |
+ v
+Rs50OledDeviceExchange (exact MI_01 collections)
+```
+
+The console-only route ends before `Rs50OledSession` and never invokes the
+session factory, device catalog, or HidSharp.
+
+## Hardware Boundary
+
+The physical adapter accepts only `Rs50OledTransaction` instances created by
+the closed protocol encoder. It does not expose a raw HID write API.
+
+Collection selection requires exactly one match for each confirmed endpoint:
+
+| Role | Path marker | Usage | Input | Output |
+|---|---|---:|---:|---:|
+| Root short report | `mi_01&col01` | `0xFF430701` | 7 | 7 |
+| Very-long display report | `mi_01&col03` | `0xFF430704` | 64 | 64 |
+
+Both collections must also match Logitech VID `0x046D` and RS50 PID `0xC276`.
+Ambiguous, missing, additional-usage, or wrong-length collections fail before
+opening a stream.
+
+Each transaction performs one write. It reads at most 16 very-long reports to
+find the exact matching response or matching HID++ error. It does not retry a
+write, acquire DirectInput, invoke feature `0x8123`, or interact with FFB,
+TRUEFORCE, LEDs, profiles, or firmware.
+
+The identity is isolated behind a confirmed-device descriptor. That boundary
+allows a future PRO descriptor only after its VID/PID and complete collection
+contract are physically confirmed; production contains no guessed PRO ID.
+
+## Session Boundary
+
+The session:
+
+- discovers `0x8130` rather than assuming runtime index `0x12`;
+- validates public flags and protocol version zero;
+- sends only typed layouts A-J;
+- suppresses the last acknowledged frame when unchanged;
+- limits changed frames to one every 200 ms;
+- validates an exact zero-body acknowledgement;
+- permanently faults after any transport, protocol, or acknowledgement
+ failure;
+- requires disposal and explicit process restart after a failure;
+- never reconnects or retries silently.
+
+The scheduler retains the newest ordinary frame while the session is rate
+limited. A queued connection-problem frame cannot be overwritten by ordinary
+telemetry before it is acknowledged. A 200 ms application heartbeat flushes
+pending frames even when no later telemetry callback arrives. All submissions
+remain serialized.
+
+## Layout Mapping
+
+All ten confirmed layouts are selectable:
+
+| Layout | Production mapping |
+|---|---|
+| A | Blank firmware layout |
+| B | Firmware Test layout |
+| C | RPM gauge |
+| D | RPM gauge, speed indicator, compact status text |
+| E | RPM gauge, speed indicator, visual-left speed, visual-right gear |
+| F | One-character gear/status and three-character speed/value |
+| G | Same fields as F with the firmware's opposite font emphasis |
+| H | Two-row speed/gear or temporary status page |
+| I | Four-row mixed-alignment telemetry/status page |
+| J | Four-row centered telemetry/status page |
+
+RPM and speed gauges clamp to the protocol's normalized byte range.
+Maximum RPM and maximum gauge speed are configuration values. Invalid,
+negative, or non-finite telemetry produces placeholders and empty gauges.
+
+## Disabled-by-Default Integration
+
+Running with no arguments starts only the existing console display:
+
+```powershell
+dotnet run --project .\LogiDynamicDash\LogiDynamicDash.csproj
+```
+
+Any nonempty argument list that does not exactly match the stationary arming
+contract is rejected before constructing a HID session.
+
+The configuration file is a strict schema-versioned JSON object:
+
+```json
+{
+ "schemaVersion": 2,
+ "layouts": {
+ "normal": "E",
+ "brakeBias": "H",
+ "lastLap": "J",
+ "connectionProblem": "H"
+ },
+ "speedUnit": "KMH",
+ "maximumRpm": 8000,
+ "gaugeMaximumSpeed": 300
+}
+```
+
+Unknown, duplicate, missing, invalid-type, out-of-range, commented, or
+oversized configurations are rejected. Preview and simulation are available
+without HID:
+
+```text
+--preview-all --config
+--simulate-all --config
+--replay --config --telemetry
+--record-telemetry --output --duration-seconds <1-1800>
+```
+
+Schema 1 remains accepted and maps its single layout to all four modes.
+Replay files are strict, bounded JSON and never construct a physical adapter.
+Recording samples copied telemetry at no more than 5 Hz and never constructs a
+physical adapter. New recordings use replay schema 2 to retain session
+category and car identity; replay schema 1 remains accepted. The Windows
+configurator edits and previews the same strict configuration and can inspect
+schema 2 identity offline. It enables a recommended profile only for a current
+official category with an exact `CarID`; applying it requires a separate user
+click. None of these paths holds a physical-session reference.
+The deterministic failure coverage is listed in
+`docs/OFFLINE_FAULT_MATRIX.md`.
+
+The compiled hardware route requires these ten arguments in this exact order:
+
+```text
+--enable-rs50-oled-stationary-trial
+--confirm-ghub-closed
+--confirm-iracing-running
+--confirm-car-stationary-in-pits
+--confirm-rs50-dynamic-selected
+--confirm-10-second-limit
+--acknowledge-no-moving-car-use
+--config
+--confirm-settings
+```
+
+The process cancels automatically after ten seconds. While iRacing reports
+`IsOnTrackCar == true`, missing, negative, non-finite, or greater-than-0.5 m/s
+speed stops the application before another OLED frame is sent.
+
+This route is compiled for a future stationary production smoke test. Its
+presence is not authorization to run it.
+
+When that route is eventually authorized, it writes a local sanitized JSONL
+diagnostic under the user's local application-data directory. Events contain
+only UTC timestamp, operation, layout, typed result, elapsed microseconds, or
+exception type. They never contain HID paths, serial numbers, raw requests,
+raw responses, payload text, or exception messages.
+
+## Remaining Gates
+
+Before the draft production PR can be enabled for general driving:
+
+1. preserve the successful stationary, Build L, and continuous-driving
+ evidence;
+2. complete final code and product-scope review of the draft PR;
+3. keep Logitech PRO compatibility explicitly unclaimed until tested;
+4. design reconnection and longer-duration ownership as subsequent hardening.
+
+The draft may be reviewed as disabled-by-default code before those physical
+gates, but it should not advertise moving-car support.
diff --git a/LogiDynamicDash/docs/WINDOWS_PACKAGE.md b/LogiDynamicDash/docs/WINDOWS_PACKAGE.md
new file mode 100644
index 0000000..cb8a359
--- /dev/null
+++ b/LogiDynamicDash/docs/WINDOWS_PACKAGE.md
@@ -0,0 +1,76 @@
+# Windows Package
+
+## Artifact
+
+GitHub Actions produces framework-dependent `LogiDynamicDash-win-x64` and
+self-contained `LogiDynamicDash-win-x64-self-contained` artifacts after
+build, tests, formatting, safety audit, dependency audit, and package smoke
+tests all pass.
+
+The artifact is an unsigned `0.3.0-alpha` candidate. It must not be presented
+as an official Logitech product.
+
+## Requirements
+
+- 64-bit Windows
+- Microsoft .NET 10 Runtime, x64, only for the framework-dependent artifact
+- iRacing for live dashboard telemetry
+- G HUB closed while LogiDynamicDash owns the OLED interface
+
+## Integrity
+
+`SHA256SUMS.txt` contains a SHA-256 hash for every packaged file except the
+manifest itself. Verify a file in PowerShell with:
+
+```powershell
+Get-FileHash -Algorithm SHA256 .\LogiDynamicDash.exe
+```
+
+Compare the reported hash with the matching manifest line before use.
+`sbom.spdx.json` records the application and direct runtime dependencies in
+SPDX 2.3 format.
+
+## Hardware-Free Use
+
+Copy `logidynamicdash.example.json` to a writable location and edit the copy.
+Then run:
+
+```powershell
+.\LogiDynamicDash.exe --preview-all --config .\my-dashboard.json
+.\LogiDynamicDash.exe --simulate-all --config .\my-dashboard.json
+.\LogiDynamicDash.exe --replay --config .\my-dashboard.json `
+ --telemetry .\replays\mode-transitions.json
+.\LogiDynamicDash.exe --record-telemetry --output .\my-session.json `
+ --duration-seconds 300
+```
+
+These routes never enumerate or open HID devices. Recording requires iRacing
+telemetry and writes at most 5 Hz for 1–1,800 seconds. Preview, simulation,
+and replay run as package smoke tests before either artifact is uploaded.
+
+`LogiDynamicDash.Configurator.exe` edits and previews configurations without
+hardware until the user explicitly selects **Start dashboard**. While running,
+it reports OLED, iRacing, car, and category state; applies automatic category
+or CarID profiles; and reconnects the exact validated OLED interface after
+sleep or disconnection. **Stop** disposes the OLED streams. The included
+`replays\session-identity.json` remains a hardware-free identity example.
+
+Running without arguments starts the console telemetry monitor:
+
+```powershell
+.\LogiDynamicDash.exe
+```
+
+## Hardware Route
+
+For daily use, open the configurator and select **Start dashboard**. The
+equivalent console route is:
+
+```powershell
+.\LogiDynamicDash.exe --run-rs50-oled `
+ --config .\logidynamicdash.example.json
+```
+
+The package retains separately armed engineering-validation routes for
+maintainers. Their historical procedures and evidence remain on the research
+branch and they are not part of ordinary product use.
diff --git a/README.md b/README.md
index f5bb3c9..5e4e039 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,70 @@
# LogiDynamicDash
-Community telemetry display for the Dynamic OLED screen on Logitech PRO Racing Wheel and RS50.
+Community telemetry dashboard for Logitech racing-wheel Dynamic OLED screens.
+RS50 is the only physically confirmed device; PRO support remains a future
+compatibility target and is not currently claimed.
+
+The production branch now contains an offline-tested, strictly typed path for
+the ten confirmed firmware-rendered OLED layouts A-J. It discovers public
+HID++ feature `0x8130` at runtime, validates exact acknowledgements, and does
+not expose arbitrary feature IDs, functions, report bytes, graphics, or fonts.
+
+Physical validation evidence and discovery artifacts remain on the dedicated
+research branch. Production contains the validated protocol, automated tests,
+and a daily-use runtime that waits for iRacing and the RS50, reconnects after
+sleep or disconnection, and selects a profile from exact CarID and official
+iRacing category.
+
+Build and run the safe console-only mode:
+
+```powershell
+dotnet build .\LogiDynamicDash.slnx -c Release
+dotnet run --project .\LogiDynamicDash\LogiDynamicDash.csproj
+```
+
+Run the offline test suite:
+
+```powershell
+dotnet test .\LogiDynamicDash.slnx -c Release
+```
+
+Copy `logidynamicdash.example.json` and edit the copy to select layout A-J
+independently for normal, brake-bias, last-lap, and connection pages, plus
+KMH or MPH, maximum RPM, and the full-scale speed for the secondary gauge.
+Validate every layout without HID:
+
+```powershell
+LogiDynamicDash.exe --preview-all --config .\my-dashboard.json
+LogiDynamicDash.exe --simulate-all --config .\my-dashboard.json
+LogiDynamicDash.exe --replay --config .\my-dashboard.json `
+ --telemetry .\replays\mode-transitions.json
+LogiDynamicDash.exe --record-telemetry --output .\my-session.json `
+ --duration-seconds 300
+```
+
+Preview prints the four application modes for every layout. Simulation runs
+30 seconds of virtual telemetry through the real formatter, session, rate
+limit, protocol encoder, and a simulated acknowledgement exchange. Neither
+command enumerates or opens HID devices. Replay passes strict, deterministic
+telemetry scenarios through the same application controller and formatter.
+Included scenarios cover acceleration, temporary pages, and disconnect/recovery.
+The offline safety and failure coverage is summarized in
+[`LogiDynamicDash/docs/OFFLINE_FAULT_MATRIX.md`](LogiDynamicDash/docs/OFFLINE_FAULT_MATRIX.md).
+
+`LogiDynamicDash.Configurator.exe` provides the Windows editor and dashboard
+launcher for per-mode layouts, speed units, gauge scales, reviewed Sports Car,
+Formula Car, Oval, Dirt Oval, and Dirt Road defaults, and typed previews. Live iRacing
+session metadata records the official event category and exact driver car
+identity (`CarID`, path, names, class, and electric flag) without guessing from
+track or driving behavior. The configurator can inspect a schema 2 telemetry
+replay, show car → category → recommendation, and apply the recommendation
+only after an explicit click. It can save category or exact-CarID overrides,
+configure the `LAST LAP` duration, and explicitly start or stop OLED output.
+Try `replays/session-identity.json` without hardware. Product scope,
+discipline rationale, and Free/Pro planning are
+documented in
+[`LogiDynamicDash/docs/PRODUCT_AND_MONETIZATION_PLAN.md`](LogiDynamicDash/docs/PRODUCT_AND_MONETIZATION_PLAN.md).
+
+See
+[`LogiDynamicDash/docs/GETTING_STARTED.md`](LogiDynamicDash/docs/GETTING_STARTED.md)
+for normal use. Historical physical-test procedures and raw evidence remain on
+the research branch.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..00ce4d6
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,24 @@
+# Third-Party Notices
+
+LogiDynamicDash uses the following top-level NuGet dependencies. Their source
+code was not copied into this repository.
+
+## HIDSharp 2.6.4
+
+- Copyright 2010-2025 James F. Bellinger
+- Project:
+- License: Apache License 2.0
+
+The NuGet package includes its complete `LICENSE.txt`.
+
+## SVappsLAB.iRacingTelemetrySDK 2.1.0
+
+- Copyright 2026 SVappsLAB
+- Project:
+- License: Apache License 2.0
+
+The NuGet package includes its complete `LICENSE`.
+
+NuGet restores these packages and their transitive dependencies during the
+build. Distributors should preserve the license files and notices included
+with redistributed package binaries.
diff --git a/logidynamicdash.example.json b/logidynamicdash.example.json
new file mode 100644
index 0000000..d34e67f
--- /dev/null
+++ b/logidynamicdash.example.json
@@ -0,0 +1,12 @@
+{
+ "schemaVersion": 2,
+ "layouts": {
+ "normal": "E",
+ "brakeBias": "H",
+ "lastLap": "J",
+ "connectionProblem": "H"
+ },
+ "speedUnit": "KMH",
+ "maximumRpm": 8000,
+ "gaugeMaximumSpeed": 300
+}
diff --git a/replays/acceleration.json b/replays/acceleration.json
new file mode 100644
index 0000000..ea11583
--- /dev/null
+++ b/replays/acceleration.json
@@ -0,0 +1,49 @@
+{
+ "schemaVersion": 1,
+ "events": [
+ {
+ "atMilliseconds": 0,
+ "statusChanged": true,
+ "connectionState": "CONNECTED",
+ "isOnTrack": true,
+ "gear": 0,
+ "rpm": 900,
+ "speedMetersPerSecond": 0,
+ "brakeBiasPercent": 52.3,
+ "lastLapTimeSeconds": null
+ },
+ {
+ "atMilliseconds": 250,
+ "statusChanged": false,
+ "connectionState": "CONNECTED",
+ "isOnTrack": true,
+ "gear": 1,
+ "rpm": 3500,
+ "speedMetersPerSecond": 8,
+ "brakeBiasPercent": 52.3,
+ "lastLapTimeSeconds": null
+ },
+ {
+ "atMilliseconds": 500,
+ "statusChanged": false,
+ "connectionState": "CONNECTED",
+ "isOnTrack": true,
+ "gear": 2,
+ "rpm": 6100,
+ "speedMetersPerSecond": 20,
+ "brakeBiasPercent": 52.3,
+ "lastLapTimeSeconds": null
+ },
+ {
+ "atMilliseconds": 750,
+ "statusChanged": false,
+ "connectionState": "CONNECTED",
+ "isOnTrack": true,
+ "gear": 3,
+ "rpm": 7600,
+ "speedMetersPerSecond": 35,
+ "brakeBiasPercent": 52.3,
+ "lastLapTimeSeconds": null
+ }
+ ]
+}
diff --git a/replays/disconnect.json b/replays/disconnect.json
new file mode 100644
index 0000000..281faab
--- /dev/null
+++ b/replays/disconnect.json
@@ -0,0 +1,38 @@
+{
+ "schemaVersion": 1,
+ "events": [
+ {
+ "atMilliseconds": 0,
+ "statusChanged": true,
+ "connectionState": "CONNECTED",
+ "isOnTrack": false,
+ "gear": 0,
+ "rpm": 900,
+ "speedMetersPerSecond": 0,
+ "brakeBiasPercent": 52.3,
+ "lastLapTimeSeconds": null
+ },
+ {
+ "atMilliseconds": 100,
+ "statusChanged": true,
+ "connectionState": "ERROR",
+ "isOnTrack": null,
+ "gear": null,
+ "rpm": null,
+ "speedMetersPerSecond": null,
+ "brakeBiasPercent": null,
+ "lastLapTimeSeconds": null
+ },
+ {
+ "atMilliseconds": 400,
+ "statusChanged": true,
+ "connectionState": "CONNECTED",
+ "isOnTrack": false,
+ "gear": 0,
+ "rpm": 900,
+ "speedMetersPerSecond": 0,
+ "brakeBiasPercent": 52.3,
+ "lastLapTimeSeconds": null
+ }
+ ]
+}
diff --git a/replays/mode-transitions.json b/replays/mode-transitions.json
new file mode 100644
index 0000000..a26aa9a
--- /dev/null
+++ b/replays/mode-transitions.json
@@ -0,0 +1,49 @@
+{
+ "schemaVersion": 1,
+ "events": [
+ {
+ "atMilliseconds": 0,
+ "statusChanged": true,
+ "connectionState": "CONNECTED",
+ "isOnTrack": true,
+ "gear": 3,
+ "rpm": 6200,
+ "speedMetersPerSecond": 30,
+ "brakeBiasPercent": 52.0,
+ "lastLapTimeSeconds": 91.2
+ },
+ {
+ "atMilliseconds": 250,
+ "statusChanged": false,
+ "connectionState": "CONNECTED",
+ "isOnTrack": true,
+ "gear": 3,
+ "rpm": 6300,
+ "speedMetersPerSecond": 31,
+ "brakeBiasPercent": 52.5,
+ "lastLapTimeSeconds": 91.2
+ },
+ {
+ "atMilliseconds": 2500,
+ "statusChanged": false,
+ "connectionState": "CONNECTED",
+ "isOnTrack": true,
+ "gear": 4,
+ "rpm": 6400,
+ "speedMetersPerSecond": 40,
+ "brakeBiasPercent": 52.5,
+ "lastLapTimeSeconds": 90.8
+ },
+ {
+ "atMilliseconds": 5750,
+ "statusChanged": false,
+ "connectionState": "CONNECTED",
+ "isOnTrack": true,
+ "gear": 4,
+ "rpm": 6500,
+ "speedMetersPerSecond": 42,
+ "brakeBiasPercent": 52.5,
+ "lastLapTimeSeconds": 90.8
+ }
+ ]
+}
diff --git a/replays/session-identity.json b/replays/session-identity.json
new file mode 100644
index 0000000..5b9e0cb
--- /dev/null
+++ b/replays/session-identity.json
@@ -0,0 +1,30 @@
+{
+ "schemaVersion": 2,
+ "events": [
+ {
+ "atMilliseconds": 0,
+ "statusChanged": true,
+ "connectionState": "CONNECTED",
+ "isOnTrack": false,
+ "gear": 0,
+ "rpm": 900,
+ "speedMetersPerSecond": 0,
+ "brakeBiasPercent": 52.3,
+ "lastLapTimeSeconds": null,
+ "sessionIdentity": {
+ "discipline": "SportsCar",
+ "rawCategory": "SportsCar",
+ "trackType": "road course",
+ "car": {
+ "carId": 1001,
+ "carPath": "cars/example-gt",
+ "displayName": "Example GT",
+ "shortName": "GT",
+ "carClassId": 2001,
+ "carClassShortName": "GT Class",
+ "isElectric": false
+ }
+ }
+ }
+ ]
+}
diff --git a/scripts/Complete-WindowsPackage.ps1 b/scripts/Complete-WindowsPackage.ps1
new file mode 100644
index 0000000..eade797
--- /dev/null
+++ b/scripts/Complete-WindowsPackage.ps1
@@ -0,0 +1,109 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)]
+ [string] $PackageRoot,
+
+ [Parameter(Mandatory)]
+ [string] $Version
+)
+
+$ErrorActionPreference = "Stop"
+
+$repositoryRoot =
+ (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+$resolvedPackageRoot =
+ (Resolve-Path -LiteralPath $PackageRoot).Path
+
+Copy-Item `
+ -LiteralPath (Join-Path $repositoryRoot "logidynamicdash.example.json") `
+ -Destination $resolvedPackageRoot `
+ -Force
+Copy-Item `
+ -LiteralPath (Join-Path $repositoryRoot "README.md") `
+ -Destination $resolvedPackageRoot `
+ -Force
+Copy-Item `
+ -LiteralPath (Join-Path $repositoryRoot "LICENSE") `
+ -Destination $resolvedPackageRoot `
+ -Force
+Copy-Item `
+ -LiteralPath (Join-Path $repositoryRoot "THIRD_PARTY_NOTICES.md") `
+ -Destination $resolvedPackageRoot `
+ -Force
+Copy-Item `
+ -LiteralPath (
+ Join-Path $repositoryRoot "LogiDynamicDash\docs\WINDOWS_PACKAGE.md") `
+ -Destination $resolvedPackageRoot `
+ -Force
+Copy-Item `
+ -LiteralPath (
+ Join-Path $repositoryRoot "LogiDynamicDash\docs\GETTING_STARTED.md") `
+ -Destination $resolvedPackageRoot `
+ -Force
+Copy-Item `
+ -LiteralPath (
+ Join-Path $repositoryRoot `
+ "LogiDynamicDash\docs\OFFLINE_FAULT_MATRIX.md") `
+ -Destination $resolvedPackageRoot `
+ -Force
+Copy-Item `
+ -LiteralPath (
+ Join-Path $repositoryRoot `
+ "LogiDynamicDash\docs\PRODUCT_AND_MONETIZATION_PLAN.md") `
+ -Destination $resolvedPackageRoot `
+ -Force
+$replayDestination = Join-Path $resolvedPackageRoot "replays"
+New-Item -ItemType Directory -Path $replayDestination -Force | Out-Null
+Copy-Item `
+ -Path (Join-Path $repositoryRoot "replays\*") `
+ -Destination $replayDestination `
+ -Recurse `
+ -Force
+
+& (Join-Path $PSScriptRoot "New-PackageSbom.ps1") `
+ -OutputPath (Join-Path $resolvedPackageRoot "sbom.spdx.json") `
+ -Version $Version
+
+$executable = Join-Path $resolvedPackageRoot "LogiDynamicDash.exe"
+$configurator =
+ Join-Path $resolvedPackageRoot "LogiDynamicDash.Configurator.exe"
+$configuration =
+ Join-Path $resolvedPackageRoot "logidynamicdash.example.json"
+$replay =
+ Join-Path $resolvedPackageRoot "replays\mode-transitions.json"
+
+if (-not (Test-Path -LiteralPath $configurator -PathType Leaf)) {
+ throw "The Windows package is missing LogiDynamicDash.Configurator.exe."
+}
+
+& $executable --preview-all --config $configuration | Out-Null
+if ($LASTEXITCODE -ne 0) {
+ throw "Packaged preview smoke test failed with exit code $LASTEXITCODE."
+}
+
+& $executable --simulate-all --config $configuration | Out-Null
+if ($LASTEXITCODE -ne 0) {
+ throw "Packaged simulation smoke test failed with exit code $LASTEXITCODE."
+}
+
+& $executable `
+ --replay `
+ --config $configuration `
+ --telemetry $replay |
+ Out-Null
+if ($LASTEXITCODE -ne 0) {
+ throw "Packaged replay smoke test failed with exit code $LASTEXITCODE."
+}
+
+Get-ChildItem -LiteralPath $resolvedPackageRoot -File -Recurse |
+ Where-Object { $_.Name -ne "SHA256SUMS.txt" } |
+ Sort-Object FullName |
+ Get-FileHash -Algorithm SHA256 |
+ ForEach-Object {
+ $relativePath =
+ $_.Path.Substring($resolvedPackageRoot.Length).TrimStart("\")
+ "$($_.Hash) $relativePath"
+ } |
+ Set-Content `
+ -LiteralPath (Join-Path $resolvedPackageRoot "SHA256SUMS.txt") `
+ -Encoding ascii
diff --git a/scripts/New-PackageSbom.ps1 b/scripts/New-PackageSbom.ps1
new file mode 100644
index 0000000..58518a7
--- /dev/null
+++ b/scripts/New-PackageSbom.ps1
@@ -0,0 +1,80 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)]
+ [string] $OutputPath,
+
+ [Parameter(Mandatory)]
+ [string] $Version
+)
+
+$ErrorActionPreference = "Stop"
+
+$document = [ordered]@{
+ spdxVersion = "SPDX-2.3"
+ dataLicense = "CC0-1.0"
+ SPDXID = "SPDXRef-DOCUMENT"
+ name = "LogiDynamicDash-$Version"
+ documentNamespace =
+ "https://github.com/PeposCJ/LogiDynamicDash/sbom/$Version"
+ creationInfo = [ordered]@{
+ created = (Get-Date).ToUniversalTime().ToString(
+ "yyyy-MM-ddTHH:mm:ssZ")
+ creators = @("Tool: LogiDynamicDash-New-PackageSbom.ps1")
+ }
+ packages = @(
+ [ordered]@{
+ name = "LogiDynamicDash"
+ SPDXID = "SPDXRef-Package-LogiDynamicDash"
+ versionInfo = $Version
+ downloadLocation =
+ "https://github.com/PeposCJ/LogiDynamicDash"
+ filesAnalyzed = $false
+ licenseConcluded = "MIT"
+ licenseDeclared = "MIT"
+ copyrightText = "NOASSERTION"
+ },
+ [ordered]@{
+ name = "HidSharp"
+ SPDXID = "SPDXRef-Package-HidSharp"
+ versionInfo = "2.6.4"
+ downloadLocation =
+ "https://www.nuget.org/packages/HidSharp/2.6.4"
+ filesAnalyzed = $false
+ licenseConcluded = "Apache-2.0"
+ licenseDeclared = "Apache-2.0"
+ copyrightText = "NOASSERTION"
+ },
+ [ordered]@{
+ name = "SVappsLAB.iRacingTelemetrySDK"
+ SPDXID = "SPDXRef-Package-iRacingTelemetrySDK"
+ versionInfo = "2.1.0"
+ downloadLocation =
+ "https://www.nuget.org/packages/SVappsLAB.iRacingTelemetrySDK/2.1.0"
+ filesAnalyzed = $false
+ licenseConcluded = "Apache-2.0"
+ licenseDeclared = "Apache-2.0"
+ copyrightText = "NOASSERTION"
+ }
+ )
+ relationships = @(
+ [ordered]@{
+ spdxElementId = "SPDXRef-DOCUMENT"
+ relationshipType = "DESCRIBES"
+ relatedSpdxElement = "SPDXRef-Package-LogiDynamicDash"
+ },
+ [ordered]@{
+ spdxElementId = "SPDXRef-Package-LogiDynamicDash"
+ relationshipType = "DEPENDS_ON"
+ relatedSpdxElement = "SPDXRef-Package-HidSharp"
+ },
+ [ordered]@{
+ spdxElementId = "SPDXRef-Package-LogiDynamicDash"
+ relationshipType = "DEPENDS_ON"
+ relatedSpdxElement = "SPDXRef-Package-iRacingTelemetrySDK"
+ }
+ )
+}
+
+$document |
+ ConvertTo-Json -Depth 8 |
+ Set-Content -LiteralPath $OutputPath -Encoding utf8
diff --git a/scripts/Test-Rs50OledProductionSurface.ps1 b/scripts/Test-Rs50OledProductionSurface.ps1
new file mode 100644
index 0000000..22d825c
--- /dev/null
+++ b/scripts/Test-Rs50OledProductionSurface.ps1
@@ -0,0 +1,264 @@
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = "Stop"
+
+$repositoryRoot =
+ (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+$productionRoot =
+ Join-Path $repositoryRoot "LogiDynamicDash"
+
+$sourceFiles = Get-ChildItem `
+ -LiteralPath $productionRoot `
+ -Recurse `
+ -Filter "*.cs" `
+ -File
+
+$prohibitedPatterns = @(
+ "DirectInput",
+ "0x8123",
+ "DllImport",
+ "NativeLibrary",
+ "SetFeature",
+ "Bootloader"
+)
+
+foreach ($pattern in $prohibitedPatterns) {
+ $match = $sourceFiles |
+ Select-String -SimpleMatch -Pattern $pattern |
+ Select-Object -First 1
+
+ if ($match) {
+ throw "Production source contains prohibited token '$pattern' in " +
+ "'$($match.Path)'."
+ }
+}
+
+$programPath =
+ Join-Path $productionRoot "Program.cs"
+$programText =
+ Get-Content -LiteralPath $programPath -Raw
+
+foreach ($token in @("HidSharp", "DeviceList", "Rs50OledDeviceExchange")) {
+ if ($programText.IndexOf(
+ $token,
+ [StringComparison]::Ordinal) -ge 0) {
+ throw "Program.cs directly references physical token '$token'."
+ }
+}
+
+$offlineCommandIndex = $programText.IndexOf(
+ "OfflineCommandLine.TryParse",
+ [StringComparison]::Ordinal)
+$displayFactoryIndex = $programText.IndexOf(
+ "ApplicationDisplayFactory.TryCreate",
+ [StringComparison]::Ordinal)
+
+if ($offlineCommandIndex -lt 0 -or
+ $displayFactoryIndex -lt 0 -or
+ $offlineCommandIndex -gt $displayFactoryIndex) {
+ throw "Offline commands must be routed before the display factory."
+}
+
+$factoryPath =
+ Join-Path $productionRoot `
+ "Configuration\ApplicationDisplayFactory.cs"
+$factoryText =
+ Get-Content -LiteralPath $factoryPath -Raw
+
+if ($factoryText.IndexOf(
+ "Rs50OledSessionFactory.OpenPhysicalWithLocalDiagnostics",
+ [StringComparison]::Ordinal) -lt 0) {
+ throw "The physical adapter is not isolated behind the display factory."
+}
+
+$physicalFactoryPath =
+ Join-Path $productionRoot `
+ "Diagnostics\Rs50OledSessionFactory.cs"
+$physicalFactoryText =
+ Get-Content -LiteralPath $physicalFactoryPath -Raw
+
+if ($physicalFactoryText.IndexOf(
+ "Rs50OledDeviceExchange.Open()",
+ [StringComparison]::Ordinal) -lt 0) {
+ throw "The typed physical exchange is missing from its isolated factory."
+}
+
+$runtimePath =
+ Join-Path $productionRoot "Runtime\DashboardRuntime.cs"
+$runtimeText =
+ Get-Content -LiteralPath $runtimePath -Raw
+
+foreach ($token in @(
+ "RecoveringRs50OledDisplaySink",
+ "Rs50OledSessionFactory.OpenPhysicalWithLocalDiagnostics",
+ "AutomaticRs50TelemetryFrameFormatter")) {
+ if ($runtimeText.IndexOf(
+ $token,
+ [StringComparison]::Ordinal) -lt 0) {
+ throw "The production dashboard runtime is missing '$token'."
+ }
+}
+
+$productionOptionsPath =
+ Join-Path $productionRoot `
+ "Configuration\Rs50ProductionRunOptions.cs"
+$productionOptionsText =
+ Get-Content -LiteralPath $productionOptionsPath -Raw
+
+foreach ($token in @(
+ "--run-rs50-oled",
+ "--config",
+ "--manual-profile",
+ "--last-lap-seconds")) {
+ if ($productionOptionsText.IndexOf(
+ $token,
+ [StringComparison]::Ordinal) -lt 0) {
+ throw "The production runtime contract is missing '$token'."
+ }
+}
+
+$offlineFiles = Get-ChildItem `
+ -LiteralPath (Join-Path $productionRoot "Offline") `
+ -Recurse `
+ -Filter "*.cs" `
+ -File
+
+foreach ($token in @(
+ "HidSharp",
+ "DeviceList",
+ "Rs50OledDeviceExchange",
+ "Rs50OledSessionFactory")) {
+ $match = $offlineFiles |
+ Select-String -SimpleMatch -Pattern $token |
+ Select-Object -First 1
+
+ if ($match) {
+ throw "Offline source references physical token '$token' in " +
+ "'$($match.Path)'."
+ }
+}
+
+$configuratorRoot =
+ Join-Path $repositoryRoot "LogiDynamicDash.Configurator"
+$configuratorFiles = Get-ChildItem `
+ -LiteralPath $configuratorRoot `
+ -Recurse `
+ -Filter "*.cs" `
+ -File
+
+foreach ($token in @(
+ "HidSharp",
+ "DeviceList",
+ "Rs50OledDeviceExchange",
+ "Rs50OledSessionFactory",
+ "IRs50OledSession")) {
+ $match = $configuratorFiles |
+ Select-String -SimpleMatch -Pattern $token |
+ Select-Object -First 1
+
+ if ($match) {
+ throw "Configurator source references physical token '$token' in " +
+ "'$($match.Path)'."
+ }
+}
+
+$armingPath =
+ Join-Path $productionRoot `
+ "Configuration\Rs50StationaryTrialOptions.cs"
+$armingText =
+ Get-Content -LiteralPath $armingPath -Raw
+
+$requiredArmingTokens = @(
+ "--enable-rs50-oled-stationary-trial",
+ "--confirm-ghub-closed",
+ "--confirm-iracing-running",
+ "--confirm-car-stationary-in-pits",
+ "--confirm-rs50-dynamic-selected",
+ "--confirm-10-second-limit",
+ "--acknowledge-no-moving-car-use",
+ "--confirm-settings"
+)
+
+foreach ($token in $requiredArmingTokens) {
+ if ($armingText.IndexOf(
+ $token,
+ [StringComparison]::Ordinal) -lt 0) {
+ throw "The stationary arming contract is missing '$token'."
+ }
+}
+
+$lowSpeedArmingPath =
+ Join-Path $productionRoot `
+ "Configuration\Rs50LowSpeedTrialOptions.cs"
+$lowSpeedArmingText =
+ Get-Content -LiteralPath $lowSpeedArmingPath -Raw
+
+$requiredLowSpeedTokens = @(
+ "--enable-rs50-oled-low-speed-trial",
+ "--confirm-ghub-closed",
+ "--confirm-iracing-running",
+ "--confirm-controlled-pit-lane",
+ "--confirm-rs50-dynamic-selected",
+ "--confirm-15-second-limit",
+ "--confirm-maximum-20-kmh",
+ "--acknowledge-stop-on-speed-limit",
+ "--confirm-settings",
+ "MaximumSpeedMetersPerSecond = 20f / 3.6f"
+)
+
+foreach ($token in $requiredLowSpeedTokens) {
+ if ($lowSpeedArmingText.IndexOf(
+ $token,
+ [StringComparison]::Ordinal) -lt 0) {
+ throw "The Build L arming contract is missing '$token'."
+ }
+}
+
+$drivingArmingPath =
+ Join-Path $productionRoot `
+ "Configuration\Rs50DrivingTrialOptions.cs"
+$drivingArmingText =
+ Get-Content -LiteralPath $drivingArmingPath -Raw
+
+$requiredDrivingTokens = @(
+ "--enable-rs50-oled-driving-trial",
+ "--confirm-ghub-closed",
+ "--confirm-iracing-running",
+ "--confirm-controlled-driving-session",
+ "--confirm-rs50-dynamic-selected",
+ "--acknowledge-no-speed-limit",
+ "--acknowledge-manual-stop-required",
+ "--confirm-settings"
+)
+
+foreach ($token in $requiredDrivingTokens) {
+ if ($drivingArmingText.IndexOf(
+ $token,
+ [StringComparison]::Ordinal) -lt 0) {
+ throw "The continuous-driving arming contract is missing '$token'."
+ }
+}
+
+$sinkPath =
+ Join-Path $productionRoot `
+ "Displays\Rs50OledDisplaySink.cs"
+$sinkText =
+ Get-Content -LiteralPath $sinkPath -Raw
+
+if ($sinkText.IndexOf(
+ "MaximumStationarySpeedMetersPerSecond = 0.5f",
+ [StringComparison]::Ordinal) -lt 0) {
+ throw "The stationary movement guard is missing or changed."
+}
+
+Write-Output (
+ "RS50 OLED production surface audit passed: no DirectInput, FFB, " +
+ "native-import, feature-report, or bootloader API was found; the " +
+ "offline commands contain no physical adapter reference; the GUI only " +
+ "reaches the exact physical adapter through the explicit production " +
+ "runtime; the runtime has bounded reconnect behavior and automatic " +
+ "profile selection; the " +
+ "physical routes remain isolated behind exact stationary, Build L, and " +
+ "continuous-driving arming contracts; bounded routes retain 0.5 m/s and " +
+ "20 km/h guards.")