The PPM protocol for encoding Remote Control channel values is now a legacy. Still, it is widely accepted by different hardware and when tinkering with Arduino, remote control, and working on own accessories for flight controllers, PPM is still a valid option.

A few years ago I presented a code that allows generating PPM stream using Arduino and AVR hardware. That solution is very hardware-specific and works only with ATMega microcontrollers.

During my work of ESP32 DiyMotionController I stumbled on a problem: if ESP32 is not compatible with AVR when timers are a concern, how to generate a PPM stream on ESP32?

PPM signal on an oscilloscope

This is why I reserved one evening, powered up my oscilloscope (Rigol DS1054Z), and created the code you can see below.

Update 2021-07-19

I've updated the source code to send all 8 channels, not only first 3. Due to this problem, some PPM decoders (Betaflight) were not able to decode the signal. They were expecting at least 4 channels, but receiving only 3. This code is proven to be working with both INAV and Betaflight. You can get the code from GitHub as well.

Arduino code for PPM generation on ESP32

    
#define PPM_FRAME_LENGTH 22500
#define PPM_PULSE_LENGTH 300
#define PPM_CHANNELS 8
#define DEFAULT_CHANNEL_VALUE 1500
#define OUTPUT_PIN 14

uint16_t channelValue[PPM_CHANNELS] = {1500, 1500, 1500, 1500, 1500, 1500, 1500, 1500};

hw_timer_t *timer = NULL;
portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED;

enum ppmState_e
{
    PPM_STATE_IDLE,
    PPM_STATE_PULSE,
    PPM_STATE_FILL,
    PPM_STATE_SYNC
};

void IRAM_ATTR onPpmTimer()
{

    static uint8_t ppmState = PPM_STATE_IDLE;
    static uint8_t ppmChannel = 0;
    static uint8_t ppmOutput = LOW;
    static int usedFrameLength = 0;
    int currentChannelValue;

    portENTER_CRITICAL(&timerMux);

    if (ppmState == PPM_STATE_IDLE)
    {
        ppmState = PPM_STATE_PULSE;
        ppmChannel = 0;
        usedFrameLength = 0;
        ppmOutput = LOW;
    }

    if (ppmState == PPM_STATE_PULSE)
    {
        ppmOutput = HIGH;
        usedFrameLength += PPM_PULSE_LENGTH;
        ppmState = PPM_STATE_FILL;

        timerAlarmWrite(timer, PPM_PULSE_LENGTH, true);
    }
    else if (ppmState == PPM_STATE_FILL)
    {
        ppmOutput = LOW;
        currentChannelValue = channelValue[ppmChannel];

        ppmChannel++;
        ppmState = PPM_STATE_PULSE;

        if (ppmChannel >= PPM_CHANNELS)
        {
            ppmChannel = 0;
            timerAlarmWrite(timer, PPM_FRAME_LENGTH - usedFrameLength, true);
            usedFrameLength = 0;
        }
        else
        {
            usedFrameLength += currentChannelValue - PPM_PULSE_LENGTH;
            timerAlarmWrite(timer, currentChannelValue - PPM_PULSE_LENGTH, true);
        }
    }
    portEXIT_CRITICAL(&timerMux);
    digitalWrite(OUTPUT_PIN, ppmOutput);
}

void setup()
{
    pinMode(OUTPUT_PIN, OUTPUT);
    timer = timerBegin(0, 80, true);
    timerAttachInterrupt(timer, &onPpmTimer, true);
    timerAlarmWrite(timer, 12000, true);
    timerAlarmEnable(timer);
}

void loop()
{
    /*
    Here you can modify the content of channelValue array and it will be automatically
    picked up by the code and outputted as PPM stream. For example:
    */
    channelValue[0] = 1750;
    channelValue[1] = 1350;
    channelValue[2] = 1050;
    channelValue[3] = 1920;
}

the code uses one of ESP32 Timers/Alarm to generate PPM in the background. Logic happens inside of onPpmTimer handler function. Code rescheduled the timer alarm to trigger according to RC channel values. The given example allows to encode 8 channels in a PPM stream. It's fully asynchronous from the main loop.