How the Multiple Pedal Synchronization and Assignment System Works in DIY Sim-Racing FFB Pedals

The DIY Sim-Racing FFB Pedal system synchronizes up to three wireless pedals through a three-layer architecture where the SimHub plugin sends assignment commands via ESP-NOW, the ESP-32 firmware persists the role to EEPROM, and the bridge board advertises availability back to the UI.

The diy-sim-racing-ffb-pedal repository by chrgri implements a complete wireless pedal ecosystem using ESP-32 microcontrollers and a SimHub plugin. Understanding how this multiple pedal synchronization and assignment system coordinates clutch, brake, and throttle roles across independent hardware units requires examining the interplay between the C# configuration interface and the embedded C++ firmware.

System Architecture Overview

The synchronization mechanism operates through three tightly-coupled layers that handle distinct responsibilities:

  • SimHub Plugin (UI Layer): Manages user interaction and constructs assignment payloads in AssignmentConfigurationWindow.xaml.cs
  • Wireless Transport (ESP-NOW Layer): Delivers commands via the ESPNOW_lib.h protocol implementation
  • Persistence Layer (Firmware): Stores assignments in EEPROM and reboots the device, implemented in ESP32/src/Main.cpp

Each physical pedal unit maintains a dap_assignement_reg structure containing its deviceID (clutch, brake, or throttle), which determines how the bridge interprets incoming sensor data.

The Assignment Workflow

SimHub UI Layer: Initiating the Assignment

When a user assigns an unassigned pedal to a specific role, the plugin creates a DAP_action_st payload targeting the pedal's MAC address. The process begins in SimHubPlugin/UIFunction/AssignmentConfigurationWindow.xaml.cs:

// Lines 11-22: Constructing the assignment action
DAP_action_st tmp_action = default;
tmp_action.payloadHeader_.version      = (byte)Constants.pedalConfigPayload_version;
tmp_action.payloadHeader_.payloadType  = (byte)Constants.pedalActionPayload_type;
tmp_action.payloadHeader_.PedalTag    = (byte)_pedalSelect;                     // Index of unassigned pedal MAC
tmp_action.payloadPedalAction_.system_action_u8 = (byte)_pedalActionId[_plugin.Settings.table_selected];

_plugin.SendPedalActionWireless(tmp_action, (byte)_pedalSelect);

The system_action_u8 field receives one of three enum values defined in SimHubPlugin/VariablesStruct/constants.cs: SET_ASSIGNMENT_0 (clutch), SET_ASSIGNMENT_1 (brake), or SET_ASSIGNMENT_2 (throttle). The SendPedalActionWireless method then transmits this packet via ESP-NOW to the target MAC address stored in _calculations.unassignedPedalMacaddress.

Wireless Transport Layer: ESP-NOW Protocol Handling

Upon reception, the ESP-32 firmware in ESP32/include/ESPNOW_lib.h decodes the action inside the onRecv callback:

// Excerpt from ESPNOW_lib.h (lines 63-80)
if (dap_actions_st.payloadPedalAction_.system_action_u8 == (uint8_t)PedalSystemAction::SET_ASSIGNMENT_0 && commandForAssignment_b)
{
    dap_assignement_reg.deviceID = PEDAL_ID_CLUTCH;
    assignmentUpdate_b = true;
    assignmentUpdateBuzzer_b = true;
}

The same conditional block exists for SET_ASSIGNMENT_1 (mapping to PEDAL_ID_BRAKE) and SET_ASSIGNMENT_2 (mapping to PEDAL_ID_THROTTLE). Setting assignmentUpdate_b triggers the persistence routine, while assignmentUpdateBuzzer_b provides audible feedback confirming receipt.

EEPROM Persistence and Runtime Activation

The main loop in ESP32/src/Main.cpp (around line 3640) checks the update flag and commits the new assignment to non-volatile storage:

if (assignmentUpdate_b)
{
    assignmentUpdate_b = false;
    writeAssignmentToEeprom();   // Persists dap_assignement_reg struct with checksum
    delay(1000);
    ESP.restart();               // Reboot to advertise new deviceID
}

The writeAssignmentToEeprom() function (lines 799-804) writes the complete dap_assignement_reg structure—including the deviceID, payloadType, magicKey, and CRC—to a fixed EEPROM offset. The mandatory ESP.restart() ensures the pedal initializes with its new logical identity and begins reporting the correct pedal type in subsequent bridge state packets.

Bridge Synchronization and UI Updates

After the ESP-32 reboots, it includes its assigned role in every bridge state transmission. The SimHub plugin receives this in SimHubPlugin/UICallback/BridgeSerialTimer*.cs:

// Line 455: Updating pedal availability status
Plugin._calculations.PedalAvailability[0] = bridge_state.payloadBridgeState_.Pedal_availability_0 == 1;

The PedalAvailability boolean array indicates which logical pedals (indices 0, 1, 2 for clutch, brake, throttle) are currently connected and synchronized. When the UI detects a transition from unassigned to assigned, AssignmentConfigurationWindow.xaml.cs (lines 65-73) updates the interface to disable the assignment button and display the current role, completing the synchronization loop.

Practical Code Examples

Triggering Assignment Programmatically in C#

To assign a detected pedal programmatically from the SimHub plugin:

int roleIdx = 1; // 0=Clutch, 1=Brake, 2=Throttle
int macIndex = 2; // Third unassigned MAC in the discovery list

var action = new DAP_action_st();
action.payloadHeader_.version      = (byte)Constants.pedalConfigPayload_version;
action.payloadHeader_.payloadType  = (byte)Constants.pedalActionPayload_type;
action.payloadHeader_.PedalTag    = (byte)macIndex;

var assignments = new[] {
    (int)PedalSystemAction.SET_ASSIGNMENT_0,
    (int)PedalSystemAction.SET_ASSIGNMENT_1,
    (int)PedalSystemAction.SET_ASSIGNMENT_2
};
action.payloadPedalAction_.system_action_u8 = (byte)assignments[roleIdx];

_plugin.SendPedalActionWireless(action, (byte)macIndex);

Handling Assignment in ESP-32 Firmware

To process the assignment command on the embedded side:

// Inside ESPNOW_lib.h receive callback
if (dap_actions_st.payloadPedalAction_.system_action_u8 == (uint8_t)PedalSystemAction::SET_ASSIGNMENT_1)
{
    dap_assignement_reg.deviceID = PEDAL_ID_BRAKE;  // Assign as brake
    assignmentUpdate_b = true;                      // Trigger EEPROM write
    assignmentUpdateBuzzer_b = true;                // Audible confirmation
}

Clearing Previous Assignments

To clear an assignment and return a pedal to unassigned status, the system uses PedalSystemAction.CLEAR_ASSIGNMENT (defined in constants.cs), which calls clearAssignmentToEeprom() in Main.cpp to reset the EEPROM region.

Summary

  • Three-layer architecture: The system combines a SimHub C# UI, ESP-NOW wireless transport, and ESP-32 EEPROM persistence to manage pedal roles

  • Enum-based assignment: SET_ASSIGNMENT_0/1/2 map to clutch/brake/throttle through the PedalSystemAction enum in constants.cs

  • Persistent storage: Assignments survive reboots via writeAssignmentToEeprom() and require an ESP.restart() to activate

  • Bridge synchronization: BridgeSerialTimer*.cs maintains PedalAvailability state, keeping the UI synchronized with hardware connections

  • MAC-based targeting: Unassigned pedals are identified by their unique MAC addresses stored in _calculations.unassignedPedalMacaddress

Frequently Asked Questions

How does the system distinguish between clutch, brake, and throttle assignments?

The system uses the PedalSystemAction enum defined in SimHubPlugin/VariablesStruct/constants.cs to specify roles. Values SET_ASSIGNMENT_0, SET_ASSIGNMENT_1, and SET_ASSIGNMENT_2 map to PEDAL_ID_CLUTCH, PEDAL_ID_BRAKE, and PEDAL_ID_THROTTLE respectively in the ESP-32 firmware. The deviceID field in the dap_assignement_reg structure stores this assignment persistently in EEPROM.

Why does the ESP-32 restart after receiving an assignment command?

The firmware calls ESP.restart() in ESP32/src/Main.cpp after invoking writeAssignmentToEeprom() to ensure the pedal initializes with its new logical identity from non-volatile storage. This reboot guarantees that the dap_assignement_reg.deviceID is active before the pedal begins transmitting bridge state packets, preventing identity confusion during the synchronization process.

What prevents multiple pedals from claiming the same logical role?

The assignment logic in AssignmentConfigurationWindow.xaml.cs filters the _unassignedPedalList to show only pedals without current role assignments. Once a pedal is assigned, the PedalAvailability array updates via BridgeSerialTimer*.cs, and the UI disables the assignment button for that logical slot. The firmware does not enforce exclusive roles at the ESP-32 level; coordination happens through the SimHub plugin's state management.

Can assignments persist across power cycles without the SimHub plugin?

Yes. The writeAssignmentToEeprom() function in ESP32/src/Main.cpp writes the assignment to the ESP-32's EEPROM with a checksum. When the pedal boots, it reads this stored configuration from EEPROM and assumes the assigned role immediately, allowing the hardware to function with the bridge board even if the SimHub PC is offline.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →