Introduction: SBUS to PPM and PWM Decoder Using Arduino Timer Interrupts. PART 2: SBUS to PPM (Trainer Port) Converter

About: Hardware and firmware freelance developer proficient in Arduino C++ and PIC assembler. Experienced in microprocessed devices, IOT and PC peripherals. Bilingual (Spanish/English). Available for hire.

This project is a set of programs used to decode Futaba’s Serial Bus (SBUS) protocol and output the received values via a Serial port, a PPM stream (for use with flight simulator USB dongles) and/or multiple PWM servo outputs (up to 12). The input is always an inverted pulse train which must be connected to a hardware serial port in the target MCU.

Click here to go to PART 1 of this instructable


Target audience:

The project is targeted at Arduino developers who would like to learn and understand how to use bare bones hardware timer interrupts for various Arduino processors, namely Pro Micro (ATMega32U4), Nano (ATMega328P), STM32F103 (bluepill) and ESP8266 (ESP01, ESP12, nodeMCU, Wemos D1 mini, etc) by analyzing the source code which does not use third party libraries nor external calls. The beauty of this project is that it has no display, no buttons to debounce, no external hardware. It’s a simple signal processor nicely fitted to learn interrupts.

The project is also targeted at radio control hobbyists who have knowledge of Arduino and its IDE.

It’s not meant as a way to learn Arduino nor to teach how to setup a programming environment for the target MCU (like the ST-Link V2 necessary to program the bluepill MCU). I will assume you know all this and have some knowledge of electronics (resistors, transistors, Arduinos, etc.)

You will need to build a signal inverter with a single NPN transistor and a couple of resistors. In the case of the ESP01 circuit below, the signal inverter will also work as a level shifter from 5V to the 3.3V required by the MCU.

Disclaimer:

No Warranty: THE SUBJECT SOFTWARE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY THAT THE SUBJECT SOFTWARE WILL CONFORM TO SPECIFICATIONS, ANY IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR FREEDOM FROM INFRINGEMENT, ANY WARRANTY THAT THE SUBJECT SOFTWARE WILL BE ERROR FREE, OR ANY WARRANTY THAT DOCUMENTATION, IF PROVIDED, WILL CONFORM TO THE SUBJECT SOFTWARE.

In other words: use at YOUR OWN RISK, if you crash a 50000 dollar R/C Jet because an SBUS decoder fails or stops responding you are on your own. As a matter of fact, if you have a 50000 dollar R/C jet (or a 100 dollar balsa R/C plane for that matter) I suggest you buy a commercial SBUS decoder when needed.

License:

Attribution Non-commercial (by-nc)

Description:

These interrupt routines are different for every MCU platform and sometimes require low level access to MCU registers (i.e. ATMega328P and ATMega32U4 which share the same register structure), sometimes interrupts are directly supported by the board manager (but not clearly documented), but overall, the code is highly incompatible between different MCU architectures.

To make the code clear I decided to create an individual sketch for each MCU architecture and refrained from using conditional ifs to compile a single sketch with multiple MCU architectures and different interrupt structures. You will find a list of target MCUs in the header of each .ino file. Unless explicitly mentioned, no third party libs are used.

All the sketches in this instructable were created using Arduino IDE 2.2.1 with the latest Board Manager of each target MCU.


PART 2:

The PPM protocol and an SBUS to PPM converter for Arduino Pro Micro


Supplies

  • Arduino Pro Micro or Leonardo (be careful, the Pro Mini is not a the same as the Pro Micro) with USB cable
  • Transistor: BC337 or BC547
  • Resistors: 470ohm, 2K2 and 2 x 10K
  • LED: 3mm red
  • Female "imperial" (2.54mm) PCB pin strip: two 12 pin pieces to make a socket for the Pro Micro
  • Male Pin strip: 2 pins for the jumper (and 1 jumper)
  • Receiver plug: male connector (from a Futaba extension cable) or equivalent Dupont connector
  • Plug: 3.5mm (mono or stereo depending on the target dongle)
  • A piece of 3 way cable for the 3.5mm plug (a piece from the Futaba extension or the Dupont cables will do)
  • A piece of perforated prototyping PCB (see photo)
  • A piece of exposed wire or Wire Wrapping wire

Step 1: Trainer Port PPM Protocol

In the next step we will be converting the SBUS signal from our receiver to a PPM signal which can be fed into an R/C USB dongle so we can cut the cord and use our radio with a flight simulator wirelessly.

The PPM (Pulse Position Modulation) protocol is a pulse train containing the somewhat time compressed data for n channels (we will use 8 channels) and a 10.5mS train separation pause. The protocol is idle HIGH and channels are separated by 0.5mS pauses. Each channel pulse is 0.4 to 1.6 mS against the 0.9 to 2.1mS in a servo pulse (this is the compression I mentioned).

Notice that you can decode PWM servo pulses as soon as you receive the PPM signal for each servo, because if you add up the 0.5mS start pauses to the servo pulses you obtain exactly 0.9 to 2.1mS, which is the actual servo pulse width. The sketch to do exactly that is listed in STEP 5 of PART 4 of this instructable.

Step 2: SBUS to PPM Converter for Arduino Pro Micro

Next sketch decodes SBUS and outputs a flawless 8 channel PPM stream. Flawless because train separation is (timer) interrupt driven using double buffering and is exactly 10.5mS, not dependant on the MCU timing. SBUS data is received concurrently (while PPM is output) making use of the MCU’s UART buffer (which is usually 64 bytes) in the main loop.

Don't go crazy trying to match the prototype in the photo, the PCB above is a cleaned up version of the handmade board. I never built a prototype with the final PCB.

You will notice that the mini plug I used for the PPM output is stereo. That's because the dongle I tested with has a switchable 5V output in the middle barrel. You should use a *mono* plug (tip is signal) if your dongle does not have this output. You will obviously need to power both the MCU and the receiver with 5V from another source.

WARNING: Do *NOT* connect the USB cable to the MCU if using power from a dongle (!).

Also please notice that if the MCU you are using has a single serial port you must not plug the USB port to power this device because the USB bridge will conflict with the serial port and you may blow your Futaba receiver (!). Needless to say, even if your receiver is not burnt it will just not work. You could hack a USB cable to power the device if you cut the (white and green) cables and leave only the power cables.

This sketch will work with Arduino Pro Micro, Leonardo, Nano or UNO (no debugging possible with Nano or UNO because they have a single Serial port).

CODE:

// SBUS to PPM protocol converter
// (c) 2023 Pablo Montoreano

/*********************************************************
  @file       SBUS2PPM8.ino
  @brief      SBUS to PPM protocol converter
  @author     Pablo Montoreano
  @copyright  2023 Pablo Montoreano
  @version    1.1 - 05/oct/23 - bug fix (0x0F is a valid SBUS value)

  no 3rd party libraries used

  for Arduino Pro Micro (ATMega32U4) or Arduino Leonardo (I needed at least 2 serial ports for debugging)
  added support for Arduino Nano or UNO (no debugging possible)
  Compile as Arduino Leonardo if using a Pro Micro
*********************************************************/

// Pro Micro: PPM output in pin 2 (D1). Connect inverted SBUS signal to RX1
// Leonardo: change const PPM_out (below) to desired output pin. Connect inverted SBUS signal to RX1
// Nano: PPM output in pin 2 (D2). Connect inverted SBUS signal to RX0
// UNO: change const PPM_out (below) to desired output pin. Connect inverted SBUS signal to RX0 (D0)

// configuration port defined below (portCfg). Use 10K pullup and jumper to GND
// no signal LED: (LED_noSignal) port via 470 ohm resistor to anode, cathode to GND

// PPM signal is idle high, low 0.5 ms (start), 0.400 to 1.600 milliseconds channel pulses, 0.5 ms channel separation
// pulse train separation is 10.5 ms

#define SHOWSIGNAL
#define LEDON HIGH
#define LEDOFF LOW
static const unsigned int PPM_out= 2;  // PPM output port
static const unsigned int portCfg= 9;  // resolution configuration (Jumper= 10 bits)
static const unsigned int LED_noSignal= 3; // low speed indicator LED

static const unsigned int maxChan= 8;  // number of PPM channels in pulse train
static const unsigned int trainSepStart= (maxChan+1)<<1;  // 2*(maxChan+1) start of pulse train separation
static const unsigned int trainSep= 21000; // 10500 uS * 2 MHz (0.5 uS per tick using timer prescaler= 8)
static const unsigned int chanSep= 1000;   // 500 uS * 2 MHz
// failsafe in channel 3 when value below signalLost
// for chinese receivers that do not report failsafe in SBUS (i.e. Microzone MC9002)
static const unsigned int signalLost= 100;
static const unsigned long timeOutMs= 1000; // timeout if no packet received after 1 sec
static const unsigned long SBUSbaudRate= 100000; // SBUS baudrate 100K

static unsigned int sbusByte, byteNmbr;
static byte frame[25];  // SBUS frame
static unsigned int channel[17]; // 16 channels in SBUS stream + channels 17, 18 & failsafe in channel[0]
static unsigned int ppm1[maxChan+1], ppm2[maxChan+1];  // double buffering for interrupt data
static volatile unsigned int pTrain;  // pulse train pointer
static bool lock1;  // use pulse train 2 when 1 locked
static bool resol1024;  // low resolution. lose a bit for a steadier output using 10 bits instead of 11
static unsigned long lastReception;  // millis of last reception
static unsigned int i; // general counter
static bool newFrame;

// Timer1 interrupt
ISR (TIMER1_COMPA_vect) {
  TIMSK1= 0;  // disable timer
  if (pTrain == 0)
    digitalWrite(PPM_out, LOW);  // disable PPM output
  else  {
    pTrain++;
    if (pTrain < trainSepStart) { // still processing channels
      if (pTrain & 1) { // now odd, process channel separation
        digitalWrite(PPM_out, LOW);
        OCR1A= chanSep; // channel separation pulse= 0.5mS
      }
      else {
        digitalWrite(PPM_out, HIGH); // start of channel output (0.4 to 1.6 mS)
        // pTrain counts both the pulse separations and the channel pulses, that's why we have to divide
        // by two the ppm pulses index with the >> 1
        if (lock1) OCR1A= ppm2[pTrain >> 1];  // if ppm1 array is being written to use ppm2
        else OCR1A= ppm1[pTrain >> 1];
      }
    }
    else if (pTrain > trainSepStart) {  // end of 10.5mS pulse train separation, start a channel separation pulse
      digitalWrite(PPM_out, LOW);
      OCR1A= chanSep; // channel separation pulse= 0.5mS
      pTrain= 1; // start new pulse train
    }
    else {  // pTrain == trainSepStart (start pulse train separation)
      digitalWrite(PPM_out, HIGH);
      OCR1A= trainSep;
    }
    TIMSK1= bit(OCIE1A);  // start timer
  }
}

void initTimer1() {
// setup timer prescaler to 8. This is equivalent to a 2MHz timer (16MHz/8), equal to 0.5uS per tick
  cli();  // clear interrupts
  TCCR1A= 0;  // disable compare mode (for timer 3 use TCCR3A)
  TCCR1B= 0;  // no clock source (timer stopped)
  TCNT1= 0;
  OCR1A= trainSep; // defaults to pulse separation
  TCCR1B= bit(WGM12) | bit(CS11);  // WGM12 -> CTC(Clear Timer on Compare Match), only CS11 -> prescaler = 8
  //  CS12    CS11    CS10
  //  0       0       0     no clock source, timer stopped
  //  0       0       1     no prescale
  //  0       1       0     clk/8
  //  0       1       1     clk/64
  //  1       0       0     clk/256
  //  1       0       1     clk/1024
  //  1       1       0     extclk falling
  //  1       1       1     extclk rising
  TIMSK1= 0;  // disable timer
  sei();  // enable interrupts
}

void decodeChannels() {
int bitPtr;   // bit pointer in SBUS byte being decoded
int bytePtr;  // byte pointer in SBUS frame
int chan;     // channel number being decoded
int chanBit;  // current channel bit being proccessed

  channel[0]= frame[23];
  bytePtr= 1;
  bitPtr= 0;
  for (chan= 1; chan <= 16; chan++){
    channel[chan]= 0;
    for (chanBit= 0; chanBit < 11; chanBit++) {
      channel[chan] |= ((frame[bytePtr] >> bitPtr) & 1) << chanBit;
      if (++bitPtr > 7) { // change byte every 8 bits
        bitPtr=0;
        bytePtr++;
      }
    }
  }
}

bool getFrame() {
#ifdef __AVR_ATmega32U4__ // Arduino Pro Micro expected
  while (Serial1.available()) {
    sbusByte= Serial1.read();
#endif
#ifdef __AVR_ATmega328P__ // Arduino nano detected, use Serial only
  while (Serial.available()) {
    sbusByte= Serial.read();
#endif
// Bug fix: 0x0F is a valid value in the SBUS stream
// so we use a flag to detect the end of a packet (0) before enabling the capture of next frame
    if ((sbusByte == 0x0F) && newFrame) { // if this byte is SBUS start byte start counting bytes
      newFrame= false;
      byteNmbr= 0;
    }
    else if (sbusByte == 0) newFrame= true; // end of frame, enable start of next frame (to distinguish from 0x0F channel values)
    if (byteNmbr <= 24) { // 25 bytes total
      frame[byteNmbr]= sbusByte;  // save a byte
      byteNmbr++;
// if a valid frame is complete (check pointer position, start byte 0F and end byte 0)
      if ((byteNmbr == 25) && (sbusByte == 0) && (frame[0] == 0x0F)) return true;  // byteNmbr is now > 24, so this routine will now wait for next frame
    }
  }
  return false; // keep buffering
}

void setup() {
  pinMode(PPM_out, OUTPUT);
  digitalWrite(PPM_out, LOW);  // disable PPM output
  pinMode(LED_noSignal, OUTPUT);
#ifdef SHOWSIGNAL
  digitalWrite(LED_noSignal, LEDON);
#else
  digitalWrite(LED_noSignal, LEDOFF);
#endif
  pinMode(portCfg, INPUT_PULLUP);
#ifdef __AVR_ATmega32U4__ // Arduino Pro Micro
  Serial1.begin(SBUSbaudRate, SERIAL_8E2);
#endif
#ifdef __AVR_ATmega328P__ // Arduino Nano
  Serial.begin(SBUSbaudRate, SERIAL_8E2);
#endif
  byteNmbr= 255; // invalidate SBUS byte number
  newFrame= false;
  lastReception= 0;
  pTrain= 0;  // idle
  lock1= true;
  initTimer1(); // initialize timer registers
}

void loop() {
  if (getFrame()) {
    lastReception= millis();
    decodeChannels(); // decode channel bitstream into 0 - 2047 channel values
    resol1024= (digitalRead(portCfg) == LOW); // can change resolution while running
#ifndef SHOWSIGNAL    
    digitalWrite(LED_noSignal, (resol1024) ? LEDON : LEDOFF); // reflect in LED
#endif
    for (i= 1; i <= maxChan; i++) {
      // each PPM channel pulse is 400 to 1600 uS while SBUS data is 0 to 2047, ajust values to timer
      // ((int) (channel[i] * 1200.0 / 2047.0) + 400) << 1; simplified with a single float operation
      // We multiply by 2 with the << 1 because we have 2 ticks per uS in the timer and we are working in uS
      // as an alternative we can use map(channel[i],0,2047,800,3200);
      // or map(channel[i] >> 1,0,1023,800,3200); for half resolution
      if (resol1024) {
        if (lock1) ppm1[i]= 800 + (int) ((channel[i] >> 1) * 2.344895);  // low (1024) resolution
        else ppm2[i]= 800 + (int) ((channel[i] >> 1) * 2.344895);
      }
      else {
        if (lock1) ppm1[i]= 800 + (int) (channel[i] * 1.172447);  // full 2048 resolution
        else ppm2[i]= 800 + (int) (channel[i] * 1.172447);
      }
    }
    lock1= !lock1;  // new stream is now valid, switch it. Timer interrupt will output next channel from new reading
    if ((channel[3] < signalLost) || (channel[0] & 8)) { // if signal lost disable PPM out
      pTrain= 0;
      TIMSK1= 0;
      digitalWrite(PPM_out, LOW);
#ifdef SHOWSIGNAL
      digitalWrite(LED_noSignal, LEDON);
#endif
    }
    else if (pTrain == 0) { // if we got signal and PPM is idle
      OCR1A= trainSep;  // start train separation pulse= 10.5mS (1/3)
      pTrain= trainSepStart; // start new channel separation (PPM output was LOW)
      digitalWrite(PPM_out, HIGH); // start pulse for channel sep
      TCNT1= 0; // reset timer counter to 0
      TIMSK1= bit(OCIE1A);  // start timer
#ifdef SHOWSIGNAL
      digitalWrite(LED_noSignal, LEDOFF);
#endif
    }
  }
  if ((millis() - lastReception) > timeOutMs) lastReception= 0;
  if (lastReception == 0) {
    pTrain= 0;
    TIMSK1= 0;
    digitalWrite(PPM_out, LOW);
#ifdef SHOWSIGNAL
    digitalWrite(LED_noSignal, LEDON);
#endif
  }
}

Step 3: SBUS to PPM Converter for Arduino Nano

You can plug an Arduino Nano into the same PCB from previous step, but please notice (photo) it has pin 3 removed (RESET in the Nano, GROUND in the Pro Micro). If you do not remove the pin, the Nano will not start. Also notice the orientation, as the Pro Micro and Nano have the USB port in different ends. The Pro Micro USB port points to the receiver connector while the Nano USB port points to the other side (I did not make a mistake in the photos). If you check the MCU’s PCB you will notice the RX pin is always closer to the receiver input.

Once again, if you use a Nano DO NOT plug the USB! (it will conflict with the serial port used to receive SBUS data).

CODE:

// Just recompile the .INO file from the previous step changing the target MCU
// it will generate Nano code by conditional compiling


This sketch does not work with other MCUs because of the incompatibilities I mentioned above... so...

In PART 3 of this instructable we will build the ESP8266 and STM32F103 versions of the converter

Click here to go to PART 3 of this instructable