Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Added
- **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.
- **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.)
Expand Down
24 changes: 23 additions & 1 deletion src/FanaBridge.Core/Protocol/DisplayEncoder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ public DisplayEncoder(IDeviceTransport transport)
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
}

/// <summary>
/// True while the display content was last written by FanaBridge: latched by
/// an accepted display report, cleared by <see cref="Release"/>. Lets shutdown
/// cleanup skip its exit blank when the display isn't ours (mode "None" —
/// another application may own the content).
/// </summary>
public bool HasWritten { get; private set; }

/// <summary>
/// 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.
/// </summary>
public void Release()
{
lock (_sync)
HasWritten = false;
}

/// <summary>
/// Sets the 3-digit 7-segment display.
/// Matches the Linux kernel driver ftec_set_display() protocol.
Expand All @@ -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;
}
}

Expand Down
15 changes: 8 additions & 7 deletions src/FanaBridge/Adapters/DisplaySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ public class DisplaySettings
public const string DefaultMode = "Gear";

/// <summary>
/// Sentinel <see cref="DisplayMode"/> 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 <see cref="DisplayMode"/> 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.
/// </summary>
public const string ModeNone = "None";

/// <summary>
/// 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).
/// </summary>
public string DisplayMode { get; set; } = DefaultMode;

Expand Down
6 changes: 6 additions & 0 deletions src/FanaBridge/Adapters/FanatecDisplayDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ public string DisplayMode
/// </summary>
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)
{
Expand Down
95 changes: 62 additions & 33 deletions src/FanaBridge/Adapters/FanatecWheelDeviceInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -490,8 +491,14 @@ 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();
}
Comment thread
kelchm marked this conversation as resolved.
_displayTestWasActive = displayTest;

// Resolve THIS descriptor's caps override-aware — the same rule the
Expand Down Expand Up @@ -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)
{
Expand All @@ -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 ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -712,6 +700,42 @@ internal void BlankOutput()
}
}

/// <summary>
/// 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.
/// </summary>
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(
Expand All @@ -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); }
Expand Down
16 changes: 14 additions & 2 deletions src/FanaBridge/FanatecPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,15 @@ public WheelCapabilities ResolveCapsFor(DeviceConfig config)
/// identity without a SimHub host. Production cores are built only by
/// InitializeCore.
/// </summary>
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);
}

/// <summary>Called by each <see cref="Adapters.FanatecWheelDeviceInstance"/> so the
/// plugin can read the connected wheel's SimHub device name for the Control Mapper
Expand Down Expand Up @@ -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)
{
Expand Down
7 changes: 4 additions & 3 deletions src/FanaBridge/UI/ScreenSettingsPanel.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,14 @@
</StackPanel>
</styles:SHSubSection>

<!-- 7-segment display mode. Title becomes "Legacy Display Mode" (with a "None"
option) on ITM wheels; plain "Display Mode" on basic wheels. -->
<!-- 7-segment display mode. Title becomes "Legacy Display Mode" on ITM wheels;
plain "Display Mode" on basic wheels. "None" turns the drive off entirely,
leaving the display to the firmware or another application. -->
<styles:SHSubSection x:Name="sectionDisplayMode" Title="Display Mode">
<ComboBox x:Name="cmbDisplayMode"
SelectionChanged="CmbDisplayMode_SelectionChanged"
Width="200" HorizontalAlignment="Left">
<ComboBoxItem x:Name="cmbItemNone" Content="None" Tag="None" Visibility="Collapsed" />
<ComboBoxItem Content="None" Tag="None" />
<ComboBoxItem Content="Gear" Tag="Gear" />
<ComboBoxItem Content="Speed" Tag="Speed" />
<ComboBoxItem Content="Gear + Speed" Tag="GearAndSpeed" />
Expand Down
2 changes: 0 additions & 2 deletions src/FanaBridge/UI/ScreenSettingsPanel.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
39 changes: 39 additions & 0 deletions tests/FanaBridge.Tests/FanatecDisplayDriverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading
Loading