From 4080fc5bf7f357c029b23377b9236300223d759e Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Fri, 7 Aug 2026 18:17:39 -0400 Subject: [PATCH 1/6] Allow display mode "None" on basic 7-segment wheels "None" previously existed only for the legacy page on ITM wheels. Offering it on basic 7-segment wheels lets another application own the wheel display while FanaBridge keeps driving the LEDs. With mode "None" FanaBridge never writes a display report, except a single blank when switching into "None" (retried until the wheel accepts it). The blank also releases display ownership, so the disconnect, End, and plugin shutdown cleanups no longer blank content that is no longer ours. --- .../Protocol/DisplayEncoder.cs | 24 ++- src/FanaBridge/Adapters/DisplaySettings.cs | 15 +- .../Adapters/FanatecDisplayDriver.cs | 6 + .../Adapters/FanatecWheelDeviceInstance.cs | 93 ++++++--- src/FanaBridge/FanatecPlugin.cs | 16 +- src/FanaBridge/UI/ScreenSettingsPanel.xaml | 7 +- src/FanaBridge/UI/ScreenSettingsPanel.xaml.cs | 2 - .../FanatecDisplayDriverTests.cs | 39 ++++ .../FanatecWheelDeviceInstanceTests.cs | 194 +++++++++++++++++- 9 files changed, 347 insertions(+), 49 deletions(-) diff --git a/src/FanaBridge.Core/Protocol/DisplayEncoder.cs b/src/FanaBridge.Core/Protocol/DisplayEncoder.cs index c23ddb2b..f51f8db2 100644 --- a/src/FanaBridge.Core/Protocol/DisplayEncoder.cs +++ b/src/FanaBridge.Core/Protocol/DisplayEncoder.cs @@ -27,6 +27,25 @@ public DisplayEncoder(IDeviceTransport transport) _transport = transport ?? throw new ArgumentNullException(nameof(transport)); } + /// + /// True while the display content was last written by FanaBridge: latched by + /// an accepted display report, cleared by . Lets shutdown + /// cleanup skip its exit blank when the display isn't ours (mode "None" — + /// another application may own the content). + /// + public bool HasWritten { get; private set; } + + /// + /// Marks the display as handed off (the one-shot blank into mode "None" was + /// accepted): whatever appears on it next is another writer's, so shutdown + /// cleanup must not blank it. A later accepted write re-latches ownership. + /// + public void Release() + { + lock (_sync) + HasWritten = false; + } + /// /// Sets the 3-digit 7-segment display. /// Matches the Linux kernel driver ftec_set_display() protocol. @@ -44,7 +63,10 @@ public bool SetDisplay(byte seg1, byte seg2, byte seg3) _reportBuf[6] = seg2; _reportBuf[7] = seg3; - return _transport.SendCol01(_reportBuf); + bool sent = _transport.SendCol01(_reportBuf); + if (sent) + HasWritten = true; + return sent; } } diff --git a/src/FanaBridge/Adapters/DisplaySettings.cs b/src/FanaBridge/Adapters/DisplaySettings.cs index 2b32ad45..457e8534 100644 --- a/src/FanaBridge/Adapters/DisplaySettings.cs +++ b/src/FanaBridge/Adapters/DisplaySettings.cs @@ -9,17 +9,18 @@ public class DisplaySettings public const string DefaultMode = "Gear"; /// - /// Sentinel meaning "no legacy page". Offered only on ITM - /// wheels, where the legacy gear/speed page is optional; selecting it turns the - /// legacy page off. Basic 7-segment wheels never use this value. + /// Sentinel meaning "display off": FanaBridge never + /// writes to the 7-segment display (beyond a one-shot blank on the transition), + /// leaving it free for the firmware or another application to drive. On ITM + /// wheels this turns off the optional legacy gear/speed page. /// public const string ModeNone = "None"; /// - /// Display mode: "Gear", "Speed", "GearAndSpeed", or "GearUpshiftBrackets". - /// On basic 7-segment wheels this is the only display. On ITM wheels it selects the - /// optional legacy gear/speed page's mode, and "None" turns that page off. ITM - /// telemetry pages themselves are firmware-driven (chosen with the wheel button). + /// Display mode: "None", "Gear", "Speed", "GearAndSpeed", or "GearUpshiftBrackets". + /// On basic 7-segment wheels this drives the wheel's only display; on ITM wheels it + /// selects the optional legacy gear/speed page's mode. ITM telemetry pages + /// themselves are firmware-driven (chosen with the wheel button). /// public string DisplayMode { get; set; } = DefaultMode; diff --git a/src/FanaBridge/Adapters/FanatecDisplayDriver.cs b/src/FanaBridge/Adapters/FanatecDisplayDriver.cs index 1436b620..55dac741 100644 --- a/src/FanaBridge/Adapters/FanatecDisplayDriver.cs +++ b/src/FanaBridge/Adapters/FanatecDisplayDriver.cs @@ -71,6 +71,12 @@ public string DisplayMode /// public void Update(GameData data) { + // Mode "None": the display is off — the owner blanks it once on the + // transition; never write here, not even the exit blank. Belt-and-braces + // with the call-site gate, so "None" can never fall through to Gear. + if (DisplayMode == DisplaySettings.ModeNone) + return; + bool telemetryLive = data != null && data.GameRunning && data.NewData != null; if (!telemetryLive) { diff --git a/src/FanaBridge/Adapters/FanatecWheelDeviceInstance.cs b/src/FanaBridge/Adapters/FanatecWheelDeviceInstance.cs index 76d3184f..7020de35 100644 --- a/src/FanaBridge/Adapters/FanatecWheelDeviceInstance.cs +++ b/src/FanaBridge/Adapters/FanatecWheelDeviceInstance.cs @@ -62,8 +62,9 @@ public class FanatecWheelDeviceInstance : DeviceInstance, INotifyPropertyChanged // resets the display cold with no trace on the ITM channel, so the ITM // lifecycle must restart from bring-up. private int _itmWheelChangeCount; - // True once the legacy page has been blanked after switching to mode "None", - // so it is cleared once on the transition rather than every frame. + // True once the 7-segment display (the legacy page on ITM wheels) has been + // blanked after switching to mode "None", so it is cleared once on the + // transition rather than every frame. private bool _legacyBlanked; // Tracks the settings page's display test so the handback edge (test just // ended) can blank the residue and reset the driver's value latches. @@ -491,7 +492,13 @@ private void DriveHardware(FanatecPlugin plugin, ref GameData data) // waiting for the next value change. bool displayTest = plugin.DisplayTestActive; if (!displayTest && _displayTestWasActive) + { _displayManager?.Clear(); + // In mode "None" that clear only removed our test residue — the + // display goes back to not-ours, so release ownership again. + if (_displaySettings.DisplayMode == DisplaySettings.ModeNone) + plugin.Display?.Release(); + } _displayTestWasActive = displayTest; // Resolve THIS descriptor's caps override-aware — the same rule the @@ -602,24 +609,7 @@ private void DriveHardware(FanatecPlugin plugin, ref GameData data) // adds col01 traffic interleaved with col03 ITM, which can destabilise the // firmware under load, so it is opt-in — the "Legacy Display Mode" dropdown, // where "None" leaves it off. - if (_displaySettings.DisplayMode != DisplaySettings.ModeNone) - { - // No encoder means this generation cannot reach a display - // at all; a driver built around one could only throw. - if (_displayManager == null && plugin.Display != null) - _displayManager = new FanatecDisplayDriver(plugin.Display, _displaySettings); - if (!displayTest) - _displayManager?.Update(data); - _legacyBlanked = false; - } - else if (_displayManager != null && !_legacyBlanked && !displayTest) - { - // Switched to None — blank the legacy page once. Only latch - // when the blanking write was accepted, so a transient - // transport failure gets retried instead of leaving the - // page frozen on its last value. - _legacyBlanked = _displayManager.Clear(); - } + UpdateSegmentDisplay(plugin, data, displayTest); } catch (Exception ex) { @@ -634,15 +624,10 @@ private void DriveHardware(FanatecPlugin plugin, ref GameData data) } else if (displayType != DisplayType.None) { - if (_displayManager == null && plugin.Display != null) - { - _displayManager = new FanatecDisplayDriver(plugin.Display, _displaySettings); - SimHub.Logging.Current.Info( - "FanatecWheelDeviceInstance[" + _config.Capabilities.Name + "]: Created display manager"); - } - - if (!displayTest) - _displayManager?.Update(data); + // Same drive (and mode-"None" gate) as the ITM legacy page: "None" + // stops all display writes so another application can own the screen + // while FanaBridge keeps driving the LEDs. + UpdateSegmentDisplay(plugin, data, displayTest); } // ── LEDs ───────────────────────────────────────────────────── @@ -673,7 +658,10 @@ private void StopDrivingHardware() { _ledHost.StopDriving(); - _displayManager?.Clear(); + // Skip the display blank in mode "None" — the display isn't ours, and + // on an identity transition the transport may still be live. + if (_displaySettings.DisplayMode != DisplaySettings.ModeNone) + _displayManager?.Clear(); _itmDisplay?.Stop(); _itmWasRunning = false; _itmStatusSnapshot = null; // don't show a stale ITM row while disconnected @@ -712,6 +700,42 @@ internal void BlankOutput() } } + /// + /// Drives the 7-segment gear/speed display (the legacy page on ITM wheels), + /// honouring mode "None": any other mode runs the display driver each frame; + /// "None" blanks the display once on the transition — retried until the write + /// is accepted — and then never writes again, leaving the display free for + /// the firmware or another application. + /// + private void UpdateSegmentDisplay(FanatecPlugin plugin, GameData data, bool displayTest) + { + if (_displaySettings.DisplayMode != DisplaySettings.ModeNone) + { + // No encoder means this generation cannot reach a display at all; + // a driver built around one could only throw. + if (_displayManager == null && plugin.Display != null) + { + _displayManager = new FanatecDisplayDriver(plugin.Display, _displaySettings); + SimHub.Logging.Current.Info( + "FanatecWheelDeviceInstance[" + _config.Capabilities.Name + "]: Created display manager"); + } + if (!displayTest) + _displayManager?.Update(data); + _legacyBlanked = false; + } + else if (_displayManager != null && !_legacyBlanked && !displayTest) + { + // Switched to None — blank once. Only latch when the blanking write + // was accepted, so a transient transport failure gets retried instead + // of leaving the display frozen on its last value. + _legacyBlanked = _displayManager.Clear(); + // The accepted blank hands the display off: release ownership so + // shutdown cleanup won't blank another application's content later. + if (_legacyBlanked) + plugin.Display?.Release(); + } + } + public override void End() { SimHub.Logging.Current.Info( @@ -723,8 +747,13 @@ public override void End() try { PluginResolver()?.UnregisterDeviceInstance(this); } catch (Exception ex) { LogCleanupFailure("unregistering the device", ex); } - try { _displayManager?.Clear(); } - catch (Exception ex) { LogCleanupFailure("clearing the display", ex); } + // Mode "None" means the display isn't ours — the exit blank would stomp + // whatever the firmware or another application has on it. + if (_displaySettings.DisplayMode != DisplaySettings.ModeNone) + { + try { _displayManager?.Clear(); } + catch (Exception ex) { LogCleanupFailure("clearing the display", ex); } + } try { _itmDisplay?.Stop(); } catch (Exception ex) { LogCleanupFailure("stopping the ITM display", ex); } diff --git a/src/FanaBridge/FanatecPlugin.cs b/src/FanaBridge/FanatecPlugin.cs index 18e46324..60d683f4 100644 --- a/src/FanaBridge/FanatecPlugin.cs +++ b/src/FanaBridge/FanatecPlugin.cs @@ -155,7 +155,15 @@ public WheelCapabilities ResolveCapsFor(DeviceConfig config) /// identity without a SimHub host. Production cores are built only by /// InitializeCore. /// - internal void InstallWheelbaseForTest(FanatecWheelbase wheelbase) => _wheelbase = wheelbase; + internal void InstallWheelbaseForTest(FanatecWheelbase wheelbase, bool withDisplayEncoder = false) + { + _wheelbase = wheelbase; + // Real init builds the encoders in InitializeCore. Display tests opt in + // to an encoder wired to the (fake) transport; everything else leaves it + // null so the no-encoder guards keep being exercised. + if (withDisplayEncoder) + _display = new DisplayEncoder(wheelbase.Transport); + } /// Called by each so the /// plugin can read the connected wheel's SimHub device name for the Control Mapper @@ -576,7 +584,11 @@ public void FinalizePlugin() try { - _display.ClearDisplay(); + // Only blank what we wrote: if no display report was ever sent + // (every device on mode "None"), the display isn't ours and the + // exit blank would stomp another application's content. + if (_display.HasWritten) + _display.ClearDisplay(); } catch (Exception ex) { diff --git a/src/FanaBridge/UI/ScreenSettingsPanel.xaml b/src/FanaBridge/UI/ScreenSettingsPanel.xaml index ab5c1cd1..5d3ba766 100644 --- a/src/FanaBridge/UI/ScreenSettingsPanel.xaml +++ b/src/FanaBridge/UI/ScreenSettingsPanel.xaml @@ -46,13 +46,14 @@ - + - + diff --git a/src/FanaBridge/UI/ScreenSettingsPanel.xaml.cs b/src/FanaBridge/UI/ScreenSettingsPanel.xaml.cs index 3ccb2ec4..862f07ac 100644 --- a/src/FanaBridge/UI/ScreenSettingsPanel.xaml.cs +++ b/src/FanaBridge/UI/ScreenSettingsPanel.xaml.cs @@ -35,8 +35,6 @@ public void Bind(DisplaySettings settings, DisplayType displayType = DisplayType // only the mode selector. bool isItm = displayType == DisplayType.Itm; - // "None" (legacy page off) is an ITM-only choice; hide it on basic wheels. - cmbItemNone.Visibility = isItm ? Visibility.Visible : Visibility.Collapsed; SelectByTag(cmbDisplayMode, _settings.DisplayMode ?? DisplaySettings.DefaultMode); chkEnableItm.IsChecked = _settings.ItmEnabled; chkShowLapTotal.IsChecked = _settings.ItmShowLapTotal; diff --git a/tests/FanaBridge.Tests/FanatecDisplayDriverTests.cs b/tests/FanaBridge.Tests/FanatecDisplayDriverTests.cs index 27238782..a17c19b1 100644 --- a/tests/FanaBridge.Tests/FanatecDisplayDriverTests.cs +++ b/tests/FanaBridge.Tests/FanatecDisplayDriverTests.cs @@ -154,6 +154,45 @@ public void Update_NullNewData_DoesNothing() Assert.Empty(transport.SentCol01Reports); } + [Fact] + public void Update_ModeNone_NeverWrites() + { + // "None" must not fall through to the unknown-mode → Gear default, and + // must not arm the exit blank either — the display belongs to the + // firmware or another application. + var transport = new RecordingTransport(); + var driver = MakeDriver(transport, DisplaySettings.ModeNone); + + driver.Update(MakeData(gear: "4")); // live telemetry + driver.Update(NotRunningData()); // game exit + + Assert.Empty(transport.SentCol01Reports); + } + + [Fact] + public void EncoderHasWritten_LatchesOnFirstAcceptedWrite() + { + // Shutdown cleanup uses HasWritten to skip its exit blank when + // FanaBridge never touched the display (mode "None"). + var transport = new RecordingTransport(); + var encoder = new DisplayEncoder(transport); + Assert.False(encoder.HasWritten); + + transport.SendReturns = false; + encoder.DisplayGear(3); + Assert.False(encoder.HasWritten); // declined sends don't count + + transport.SendReturns = true; + encoder.DisplayGear(3); + Assert.True(encoder.HasWritten); + + encoder.Release(); // handoff into mode "None" + Assert.False(encoder.HasWritten); + + encoder.DisplayGear(4); + Assert.True(encoder.HasWritten); // a later write re-latches + } + [Fact] public void Update_UnknownMode_FallsBackToGear() { diff --git a/tests/FanaBridge.Tests/FanatecWheelDeviceInstanceTests.cs b/tests/FanaBridge.Tests/FanatecWheelDeviceInstanceTests.cs index 6dde7766..e0fdba51 100644 --- a/tests/FanaBridge.Tests/FanatecWheelDeviceInstanceTests.cs +++ b/tests/FanaBridge.Tests/FanatecWheelDeviceInstanceTests.cs @@ -41,6 +41,10 @@ private sealed class FakeTransport : IConnectableTransport { public bool Connected; public FakeReportStream Identity { get; } = new FakeReportStream(); + // Recorded col01 output (copied — the encoders reuse their buffers) so + // display tests can assert exactly what reached the wire. + public List Col01Sent { get; } = new List(); + public bool AcceptCol01 = true; public bool Connect(int productId) { Connected = true; return true; } public void Disconnect() => Connected = false; public void Dispose() => Disconnect(); @@ -49,7 +53,13 @@ private sealed class FakeTransport : IConnectableTransport public FanatecTransport.TransportConnectStatus LastConnectStatus => FanatecTransport.TransportConnectStatus.Connected; public bool SendCol03(byte[] data) => true; - public bool SendCol01(byte[] data) => true; + public bool SendCol01(byte[] data) + { + var copy = new byte[data.Length]; + Array.Copy(data, copy, data.Length); + Col01Sent.Add(copy); + return AcceptCol01; + } public IReportStream IdentityReports => Identity; public IReportStream ItmReports => FakeReportStream.Empty; public IReportStream SrmReports => FakeReportStream.Empty; @@ -85,8 +95,18 @@ private static byte[] Ff08(byte baseType, byte wire) // settled identity for the given wheel code (or none when null). private static FanatecPlugin PluginWithWheel( string? wheelCode, out FanatecWheelbase wheelbase, string? overrideProfileId = null) + => PluginWithWheel(wheelCode, out wheelbase, out _, overrideProfileId, + withDisplayEncoder: false); + + // The transport-exposing overload also installs a display encoder by + // default — it exists for the display tests, which assert on the col01 + // frames that encoder emits. + private static FanatecPlugin PluginWithWheel( + string? wheelCode, out FanatecWheelbase wheelbase, out FakeTransport transport, + string? overrideProfileId = null, bool withDisplayEncoder = true) { var t = new FakeTransport(); + transport = t; var clock = new Clock(); wheelbase = new FanatecWheelbase(t, new FakeBus(), clock.Now); if (overrideProfileId != null) @@ -103,7 +123,7 @@ private static FanatecPlugin PluginWithWheel( } var plugin = new FanatecPlugin(); - plugin.InstallWheelbaseForTest(wheelbase); + plugin.InstallWheelbaseForTest(wheelbase, withDisplayEncoder); return plugin; } @@ -1019,5 +1039,175 @@ public void End_DisposesTheLedHost_EvenIfEarlierCleanupFails() Assert.Equal(1, host.DisposeCount); } + + // ── Display mode "None" (basic 7-segment wheels) ─────────────────── + // + // "None" hands the 7-segment display to the firmware or another + // application while FanaBridge keeps driving the LEDs: no display writes + // while a game runs, one blank on the transition into "None" (retried + // until the transport accepts it), and no exit blank on End. + // Runs on CSLSWGT3 — a basic-display wheel with no LEDs, so the col01 + // stream carries display frames only. + + // StatusDataBase is abstract with internal setters (see + // FanatecDisplayDriverTests) — close StatusData over object and drive + // the internal setters via reflection. + private static readonly Type StatusDataType = + typeof(GameData).Assembly + .GetType("GameReaderCommon.StatusData`1") + .MakeGenericType(typeof(object)); + + private static GameData RunningData(string gear) + { + var status = System.Runtime.Serialization.FormatterServices + .GetUninitializedObject(StatusDataType); + StatusDataType.GetProperty("Gear")!.GetSetMethod(true)! + .Invoke(status, new object[] { gear }); + var d = new GameData { NewData = (StatusDataBase)status }; + typeof(GameData).GetProperty("GameRunning")!.GetSetMethod(true)! + .Invoke(d, new object[] { true }); + return d; + } + + /// Display control frames (01 F8 09 01 02 s1 s2 s3) on the wire. + private static List DisplayFrames(FakeTransport t) => + t.Col01Sent.Where(r => r.Length == 8 && r[1] == 0xF8 && r[2] == 0x09 + && r[3] == 0x01 && r[4] == 0x02).ToList(); + + private static FanatecWheelDeviceInstance ConnectedGt3Instance(out FakeTransport transport) + { + var plugin = PluginWithWheel("CSLSWGT3", out _, out transport); + var inst = InstanceFor("CSLSWGT3"); + inst.PluginResolver = () => plugin; + return inst; + } + + private static JObject Gt3Settings(string displayMode) => new JObject + { + ["wheelType"] = "CSLSWGT3", + ["displayMode"] = displayMode, + }; + + [Fact] + public void BasicWheel_ModeNone_WritesNothingWhileGameRuns() + { + var inst = ConnectedGt3Instance(out var transport); + inst.SetSettings(Gt3Settings("None"), isDefault: false); + + foreach (var gear in new[] { "1", "2", "3" }) + { + var data = RunningData(gear); + inst.DataUpdate(null, ref data); + } + + Assert.Empty(DisplayFrames(transport)); + } + + [Fact] + public void BasicWheel_ActiveMode_DrivesTheDisplay() + { + // Sanity for the fixture: the same harness DOES write in Gear mode, + // so the empty assertions above can't pass vacuously. + var inst = ConnectedGt3Instance(out var transport); + inst.SetSettings(Gt3Settings("Gear"), isDefault: false); + + var data = RunningData("3"); + inst.DataUpdate(null, ref data); + + Assert.NotEmpty(DisplayFrames(transport)); + } + + [Fact] + public void BasicWheel_SwitchingToNone_BlanksOnceThenStaysSilent() + { + var inst = ConnectedGt3Instance(out var transport); + inst.SetSettings(Gt3Settings("Gear"), isDefault: false); + var data = RunningData("3"); + inst.DataUpdate(null, ref data); + + inst.SetSettings(Gt3Settings("None"), isDefault: false); + transport.Col01Sent.Clear(); + inst.DataUpdate(null, ref data); + inst.DataUpdate(null, ref data); + inst.DataUpdate(null, ref data); + + var blank = Assert.Single(DisplayFrames(transport)); + Assert.Equal(SevenSegment.Blank, blank[5]); + Assert.Equal(SevenSegment.Blank, blank[6]); + Assert.Equal(SevenSegment.Blank, blank[7]); + } + + [Fact] + public void BasicWheel_NoneBlank_RetriesUntilTheTransportAccepts() + { + var inst = ConnectedGt3Instance(out var transport); + inst.SetSettings(Gt3Settings("Gear"), isDefault: false); + var data = RunningData("3"); + inst.DataUpdate(null, ref data); + + inst.SetSettings(Gt3Settings("None"), isDefault: false); + transport.AcceptCol01 = false; + transport.Col01Sent.Clear(); + inst.DataUpdate(null, ref data); // declined — must not latch + inst.DataUpdate(null, ref data); + Assert.Equal(2, DisplayFrames(transport).Count); + + transport.AcceptCol01 = true; + inst.DataUpdate(null, ref data); // accepted — latches + inst.DataUpdate(null, ref data); + Assert.Equal(3, DisplayFrames(transport).Count); + } + + [Fact] + public void BasicWheel_ModeNone_EndDoesNotBlankTheDisplay() + { + var inst = ConnectedGt3Instance(out var transport); + inst.SetSettings(Gt3Settings("Gear"), isDefault: false); + var data = RunningData("3"); + inst.DataUpdate(null, ref data); // creates the display manager + + inst.SetSettings(Gt3Settings("None"), isDefault: false); + inst.DataUpdate(null, ref data); // transition blank + transport.Col01Sent.Clear(); + + inst.End(); + + Assert.Empty(DisplayFrames(transport)); + } + + [Fact] + public void BasicWheel_SwitchingToNone_ReleasesDisplayOwnership() + { + // The accepted handoff blank must clear HasWritten so the plugin's + // shutdown cleanup no longer blanks the (now foreign) display content. + var plugin = PluginWithWheel("CSLSWGT3", out _, out var transport); + var inst = InstanceFor("CSLSWGT3"); + inst.PluginResolver = () => plugin; + + inst.SetSettings(Gt3Settings("Gear"), isDefault: false); + var data = RunningData("3"); + inst.DataUpdate(null, ref data); + Assert.True(plugin.Display.HasWritten); // gear content is ours + + inst.SetSettings(Gt3Settings("None"), isDefault: false); + inst.DataUpdate(null, ref data); // accepted handoff blank + + Assert.False(plugin.Display.HasWritten); + } + + [Fact] + public void BasicWheel_ActiveMode_EndBlanksTheDisplay() + { + var inst = ConnectedGt3Instance(out var transport); + inst.SetSettings(Gt3Settings("Gear"), isDefault: false); + var data = RunningData("3"); + inst.DataUpdate(null, ref data); + transport.Col01Sent.Clear(); + + inst.End(); + + var blank = Assert.Single(DisplayFrames(transport)); + Assert.Equal(SevenSegment.Blank, blank[5]); + } } } From e00bddbf1546fd2d24df88979f78a3730794a1b6 Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Sat, 8 Aug 2026 11:32:06 -0400 Subject: [PATCH 2/6] Blank the display once when a display test ends in mode "None" The display-test handback and the blank-once path both ran in the frame the test was released, so mode "None" sent two blanks instead of one. The handback also released display ownership before its clear was accepted, dropping it while our own test residue was still on screen. The handback now leaves mode "None" alone: the blank-once path already clears the residue, latches only on an accepted write, and releases ownership there. --- .../Adapters/FanatecWheelDeviceInstance.cs | 10 ++-- .../FanatecWheelDeviceInstanceTests.cs | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/FanaBridge/Adapters/FanatecWheelDeviceInstance.cs b/src/FanaBridge/Adapters/FanatecWheelDeviceInstance.cs index 7020de35..d067b0db 100644 --- a/src/FanaBridge/Adapters/FanatecWheelDeviceInstance.cs +++ b/src/FanaBridge/Adapters/FanatecWheelDeviceInstance.cs @@ -491,13 +491,13 @@ private void DriveHardware(FanatecPlugin plugin, ref GameData data) // latches, so the live gear/speed repaints immediately instead of // waiting for the next value change. bool displayTest = plugin.DisplayTestActive; - if (!displayTest && _displayTestWasActive) + if (!displayTest && _displayTestWasActive + && _displaySettings.DisplayMode != DisplaySettings.ModeNone) { + // In mode "None" the blank-once path clears the test residue and + // releases ownership, with retry — clearing here would double the + // write and could release ownership on a declined clear. _displayManager?.Clear(); - // In mode "None" that clear only removed our test residue — the - // display goes back to not-ours, so release ownership again. - if (_displaySettings.DisplayMode == DisplaySettings.ModeNone) - plugin.Display?.Release(); } _displayTestWasActive = displayTest; diff --git a/tests/FanaBridge.Tests/FanatecWheelDeviceInstanceTests.cs b/tests/FanaBridge.Tests/FanatecWheelDeviceInstanceTests.cs index e0fdba51..73eb8b57 100644 --- a/tests/FanaBridge.Tests/FanatecWheelDeviceInstanceTests.cs +++ b/tests/FanaBridge.Tests/FanatecWheelDeviceInstanceTests.cs @@ -1195,6 +1195,60 @@ public void BasicWheel_SwitchingToNone_ReleasesDisplayOwnership() Assert.False(plugin.Display.HasWritten); } + [Fact] + public void BasicWheel_DisplayTestEndingInNone_BlanksExactlyOnce() + { + // The handback edge and the blank-once path both run in the frame the + // test is released; only one of them may write. + var plugin = PluginWithWheel("CSLSWGT3", out _, out var transport); + var inst = InstanceFor("CSLSWGT3"); + inst.PluginResolver = () => plugin; + + inst.SetSettings(Gt3Settings("Gear"), isDefault: false); + var data = RunningData("3"); + inst.DataUpdate(null, ref data); // builds the display manager + + plugin.DisplayTestActive = true; + inst.SetSettings(Gt3Settings("None"), isDefault: false); + inst.DataUpdate(null, ref data); // test owns the display + transport.Col01Sent.Clear(); + + plugin.DisplayTestActive = false; + inst.DataUpdate(null, ref data); // handback frame + inst.DataUpdate(null, ref data); + + var blank = Assert.Single(DisplayFrames(transport)); + Assert.Equal(SevenSegment.Blank, blank[5]); + Assert.False(plugin.Display.HasWritten); // ownership handed off + } + + [Fact] + public void BasicWheel_DisplayTestHandback_KeepsOwnershipWhenTheClearIsDeclined() + { + // Releasing on a declined clear would drop ownership while our own + // test residue is still on the display. + var plugin = PluginWithWheel("CSLSWGT3", out _, out var transport); + var inst = InstanceFor("CSLSWGT3"); + inst.PluginResolver = () => plugin; + + inst.SetSettings(Gt3Settings("Gear"), isDefault: false); + var data = RunningData("3"); + inst.DataUpdate(null, ref data); + + plugin.DisplayTestActive = true; + inst.SetSettings(Gt3Settings("None"), isDefault: false); + inst.DataUpdate(null, ref data); + + transport.AcceptCol01 = false; + plugin.DisplayTestActive = false; + inst.DataUpdate(null, ref data); // handback blank declined + Assert.True(plugin.Display.HasWritten); // still ours + + transport.AcceptCol01 = true; + inst.DataUpdate(null, ref data); // retry accepted + Assert.False(plugin.Display.HasWritten); + } + [Fact] public void BasicWheel_ActiveMode_EndBlanksTheDisplay() { From c7dc31f34239de05af0da2c2b3fff228e54b09c6 Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Sat, 8 Aug 2026 11:43:03 -0400 Subject: [PATCH 3/6] Note the display-off mode in the changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b303e6..7d9493b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- **The wheel's display can now be left to another application.** Setting Display Mode to "None" stops FanaBridge writing to a wheel's 3-digit display, so the wheel's firmware or another application — Fanatec's own software, for instance — can drive it while FanaBridge keeps control of the LEDs. The display is blanked once when the mode is selected and left alone from then on. Previously this option was offered only on wheels with an ITM display, where it turns off the optional legacy gear/speed page. ([#97](https://github.com/kelchm/FanaBridge/pull/97)) + ### Fixed - **Device settings are no longer erased while FanaBridge is disabled.** SimHub rewrites each device's settings file whenever it saves, and it does so whether or not the plugin is running. FanaBridge only built its LED editor once the plugin was up, so a save taken before that — most obviously with the plugin disabled — wrote a settings file with no LED data over one that had it, replacing hand-built profiles with a stub. The editor and everything a device stores are now built with the device itself, so a device can always describe its settings. If it ever cannot, it declines to save and SimHub keeps the existing file. - **Settings written by another version are no longer dropped.** A device only wrote back the settings it recognised, so anything stored by a newer build was lost on the next save. Unrecognised settings are now kept as-is. (Settings nested inside the LED module's own data are the exception — the module rewrites those wholesale.) From a132bc139f34350bbd38d08fd67207088020b55c Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Sat, 8 Aug 2026 11:44:52 -0400 Subject: [PATCH 4/6] Say what "None" does on an ITM wheel more precisely --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d9493b1..033f1aa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Added -- **The wheel's display can now be left to another application.** Setting Display Mode to "None" stops FanaBridge writing to a wheel's 3-digit display, so the wheel's firmware or another application — Fanatec's own software, for instance — can drive it while FanaBridge keeps control of the LEDs. The display is blanked once when the mode is selected and left alone from then on. Previously this option was offered only on wheels with an ITM display, where it turns off the optional legacy gear/speed page. ([#97](https://github.com/kelchm/FanaBridge/pull/97)) +- **A wheel's 3-digit display can now be left to another application.** Setting Display Mode to "None" stops FanaBridge writing to it, so the wheel's firmware or another application — Fanatec's own software, for instance — can drive the display while FanaBridge keeps control of the LEDs. It is blanked once when the mode is selected and left alone from then on. The option itself is not new, but it was previously offered only on wheels with an ITM display, where it governs just that display's optional legacy gear/speed page; handing the ITM telemetry pages themselves to another application is the separate "Enable ITM display" setting. ([#97](https://github.com/kelchm/FanaBridge/pull/97)) ### Fixed - **Device settings are no longer erased while FanaBridge is disabled.** SimHub rewrites each device's settings file whenever it saves, and it does so whether or not the plugin is running. FanaBridge only built its LED editor once the plugin was up, so a save taken before that — most obviously with the plugin disabled — wrote a settings file with no LED data over one that had it, replacing hand-built profiles with a stub. The editor and everything a device stores are now built with the device itself, so a device can always describe its settings. If it ever cannot, it declines to save and SimHub keeps the existing file. From 8d664fcee33ede77efc6cf13661aaa97d869565e Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Sat, 8 Aug 2026 11:45:24 -0400 Subject: [PATCH 5/6] Trim the changelog entry to the point --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 033f1aa1..060b125e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Added -- **A wheel's 3-digit display can now be left to another application.** Setting Display Mode to "None" stops FanaBridge writing to it, so the wheel's firmware or another application — Fanatec's own software, for instance — can drive the display while FanaBridge keeps control of the LEDs. It is blanked once when the mode is selected and left alone from then on. The option itself is not new, but it was previously offered only on wheels with an ITM display, where it governs just that display's optional legacy gear/speed page; handing the ITM telemetry pages themselves to another application is the separate "Enable ITM display" setting. ([#97](https://github.com/kelchm/FanaBridge/pull/97)) +- **A wheel's 3-digit display can now be left to another application.** Display Mode "None" stops FanaBridge writing to it, so Fanatec's own software can drive the display while FanaBridge keeps the LEDs. The option previously existed only on ITM wheels, where it governs just the legacy gear/speed page. ([#97](https://github.com/kelchm/FanaBridge/pull/97)) ### Fixed - **Device settings are no longer erased while FanaBridge is disabled.** SimHub rewrites each device's settings file whenever it saves, and it does so whether or not the plugin is running. FanaBridge only built its LED editor once the plugin was up, so a save taken before that — most obviously with the plugin disabled — wrote a settings file with no LED data over one that had it, replacing hand-built profiles with a stub. The editor and everything a device stores are now built with the device itself, so a device can always describe its settings. If it ever cannot, it declines to save and SimHub keeps the existing file. From 47aaeeb1ba297fb8ced4b1297d8e579bc2109a6e Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Sat, 8 Aug 2026 11:51:42 -0400 Subject: [PATCH 6/6] fix changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 060b125e..486fc345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Added -- **A wheel's 3-digit display can now be left to another application.** Display Mode "None" stops FanaBridge writing to it, so Fanatec's own software can drive the display while FanaBridge keeps the LEDs. The option previously existed only on ITM wheels, where it governs just the legacy gear/speed page. ([#97](https://github.com/kelchm/FanaBridge/pull/97)) +- **A wheel's segment display can now be left to another application.** Display Mode "None" stops FanaBridge writing to it, so Fanatec's own software can drive the display while FanaBridge keeps the LEDs. The option previously existed only for the legacy page of ITM wheels. ([#97](https://github.com/kelchm/FanaBridge/pull/97)) ### Fixed - **Device settings are no longer erased while FanaBridge is disabled.** SimHub rewrites each device's settings file whenever it saves, and it does so whether or not the plugin is running. FanaBridge only built its LED editor once the plugin was up, so a save taken before that — most obviously with the plugin disabled — wrote a settings file with no LED data over one that had it, replacing hand-built profiles with a stub. The editor and everything a device stores are now built with the device itself, so a device can always describe its settings. If it ever cannot, it declines to save and SimHub keeps the existing file.