Optimising the payload for the RC Transmitter

When I first started building this RC transmitter, I quickly realised that packing all the controls into a clean, efficient radio payload was more than just plugging in values - it was a chance to make the whole system smarter and leaner.

In this tutorial, I’ll walk through how I optimised the payload for the ESP32 + SX1280 setup so that digital inputs, switches, joysticks, and other controls use as little space as possible without losing responsiveness.

rc_transmitter_all

Optimising the Digital Inputs using bit manipulation

Because I had a total of 5 switches and 10 push buttons, I was using 15 bytes instead of just a couple.

In order to achieve this, I decided to allocate a single bit for every single digital input.

C++
bool switchStatuses[15] = {
    // Store the current switches
    switch_1,
    switch_2,
    switch_3,
    switch_4,
    switch_5,

    // Rotary Encoder - Left
    left_up,
    left_down,
    left_left,
    left_right,
    left_middle,

    // Rotary Encoder - Right
    right_up,
    right_down,
    right_left,
    right_right,
    right_middle,
};

After reading everything, I had to extract the two bytes and add them to my payload.

C++
...
uint16_t Tx::encodeStatusToByte(bool statuses[], int num) {
    uint16_t encodedByte = 0;

    for (int i = 0; i < num; i++) {
        encodedByte |= (statuses[i] << i);
    }

    return encodedByte;
}
...
switches_state = Tx::encodeStatusToByte(switchStatuses, 15);

switches_state_1 = (switches_state & 0xFF);
switches_state_2 = ((switches_state >> 8) & 0xFF);

On the RX part, I only had to combine the bytes using this:

C++
...
void Rx::decodeByteToStatuses(uint16_t encodedByte, bool statuses[], int num) {
  for (int i = 0; i < num; i++) {
    statuses[i] = (encodedByte >> i) & 0x01;
  }
}
...

uint16_t combined_byte = Rx::combineBytes(
    switches_state_1, switches_state_2
);

// Decode the uint16_t back into switch statuses
bool decoded_switch_statuses[15];
Rx::decodeByteToStatuses(combined_byte, decoded_switch_statuses, 15);

Optimising the payload by allocating unique channels

Using bit manipulation, I created a config that only uses 2 bytes. The first 3 bits are reserved for the sender identification, while the other 13 bits are used to confirm if the channel was enabled or disabled.

C++
/**
 * TX Payload Config
 * TX0 : Remote TX
 * TX1 : Sensors 1
 * TX2 : Sensors 2
 * 
 * CH1  : Left Joystick Up/Down ( 1 byte )
 * CH2  : Left Joystick Left/Right ( 1 byte )
 * CH3  : Right Joystick Up/Down ( 1 byte )
 * CH4  : Right Joystick Left/Right ( 1 byte )
 * CH5  : Switches/Push Buttons ( 2 bytes )
 * CH6  : Left Rotary Encoder ( 2 bytes )
 * CH7  : Right Rotary Encoder ( 2 bytes )
 * CH8  : Potentiometer 1 ( 1 byte )
 * CH9  : Potentiometer 2 ( 1 byte )
 * CH10 : Potentiometer 3 ( 1 byte )
 * CH11 : Potentiometer 4 ( 1 byte )
 * CH12 : Potentiometer 5 ( 1 byte )
 * CH13 : Potentiometer 6 ( 1 byte )
 */
bool payload_config[16] = {
    0, 0, 0,
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
};

After that, I only had to create a new Channel structure and prepare the data.

C++
typedef struct Channel {
    uint8_t required_bytes;
    uint8_t first_byte;
    uint8_t second_byte;
};

/**
 * CH1  : Left Joystick Up/Down ( 1 byte )
 * CH2  : Left Joystick Left/Right ( 1 byte )
 * CH3  : Right Joystick Up/Down ( 1 byte )
 * CH4  : Right Joystick Left/Right ( 1 byte )
 * CH5  : Switches/Push Buttons ( 2 bytes )
 * CH6  : Left Rotary Encoder ( 2 bytes )
 * CH7  : Right Rotary Encoder ( 2 bytes )
 * CH8  : Potentiometer 1 ( 1 byte )
 * CH9  : Potentiometer 2 ( 1 byte )
 * CH10 : Potentiometer 3 ( 1 byte )
 * CH11 : Potentiometer 4 ( 1 byte )
 * CH12 : Potentiometer 5 ( 1 byte )
 * CH13 : Potentiometer 6 ( 1 byte )
 */
Channel channels[13] = {
    {1, joystick_default_value, 0},
    {1, joystick_default_value, 0},
    {1, joystick_default_value, 0},
    {1, joystick_default_value, 0},
    {2, 0, 0},
    {2, 0, 0},
    {2, 0, 0},
    {1, 0, 0},
    {1, 0, 0},
    {1, 0, 0},
    {1, 0, 0},
    {1, 0, 0},
    {1, 0, 0}
};

When sending the data, if the new value is not the same as the default one, I would just activate the channel by setting the bit to 1 instead of 0 and add the channel info in the current payload object.