Introduction: Ghost Toddler: Esp32 FPV Pan Tilt Power Wheels

About: 11-year-old boy in a man's body pretending to be a 25-year-old YouTuber. Amateur programmer, welder, fabricator, 3d designer, roboticist, electronics enthusiast, musician.

An ESP32 is the brains.

The ESP32 reads a serial stream with a 32 byte payload from an RC receiver (packaging is called "I-Bus").

The code interprets the payload into the 10 channels (e.g. joysticks and switches and stuff) from the transmitter.

The Esp32 outputs to:

-two BTS7960 dc motor drivers (wheels forward or reverse)

-one 35kg*cm servo (steering shaft pulley)

-two 25kg*cm servos (skull pan and tilt servos

-one 12V relay controlling a gas solenoid valve

-one 12V relay controlling a mini high-voltage transformer


I put an analog FPV camera in the right eye socket so I could look around with one joystick while I drove with the other joystick… Kind of like legend of Zelda.


The end product: I made a radio controlled power wheels that allowed me to FPV the experience of being the pediatric version of Ghost Rider, all accoutrements accounted for.

This project happened on a dare. I just figured out how to receive/interpret/use the serial stream from an RC receiver on an esp32 using the Arduino IDE (I was never really an RC person, but I was RC curious).

However, I had not yet completed two rites of passage for most maker-types;

1) I had never built anything really robotic, and

2) I had never built anything seriously battery-powered.

Despite this lack of experience, I proclaimed on September 26 that I thought I could do this project and publish a video on it by Halloween. I’m gonna skip straight to the “Build Instructions” part if the reader is curious on how to do this, and I am happy to lay it out.

/* Edit: Users/ Editors, let me kow if my code copy-pastes can be improved upon; it looks a little wonky */



You can read the instructions and follow along on my full build video as well:

Step 1: Interpreting an RC Receiver Serial Payload

Honestly, I saw how relatively inexpensive these transmitter/receiver bundles were, and I saw how much functionality was built into them, and I knew they worked at a longer range than Bluetooth and standard Wi-Fi (I’m not even going near ESPNow but maybe), and I had to screw around with one. 

I was doing an unrelated project using field oriented control of bushels motors, and I got the remote control to do this, which got me jazzed.

Each degree of motion (we will call them “channels,” because that is what they are called in this arena) on the transmitter is broadcast to the receiver. On the receiver side, each channel outputs a PWM signal that can directly control something, if it wants to, which we don’t want to do. Instead, we will utilize a different way to use way less wires, and it is easy and satisfying.

Instead of having a PWM line for each channel fed into the ESP32, this brand of remote has this thing called “I-Bus,” which sounds complicated, and sounds even more complicated upon learning it involves reading a serial stream of bytes from an RC receiver and turning that into meaningful information.

Luckily, as of October 2024, ChatGPT4o++^2 will be familiar with this, and upon our very detailed prompts which reflect our deep understanding of syntax and semantics of C++ (to be clear, this is sarcasm about my own proficiency), we get a pretty cool class for reading off a second UART stream to the ESP32. It gets better. We even get efficient code so as not to flood the buffer (I don’t know what that means but I’m writing it to sound knowledgeable) that reads one by every time it goes through the loop, scanning for the to start bytes, then reading the remaining 30 bytes that encode the data of every state of every doodad on the transmitter. Here is the class:

// Include necessary libraries (if any)
// Note: Since we're defining the class and using it in the same file, no additional includes are necessary.

// Define the IBusReceiver class
class IBusReceiver {
public:
// Constructor
IBusReceiver(HardwareSerial& serialPort, uint8_t rxPin) : serial(serialPort), rxPin(rxPin) {
packetIndex = 0;
packetAvailable = false;
}

// Initialize the serial port
void begin(uint32_t baudRate) {
serial.begin(baudRate, SERIAL_8N1, rxPin, -1);
}

// Non-blocking function to read IBUS data
void readIBUSData() {
while (serial.available()) {
uint8_t incomingByte = serial.read();
switch (packetIndex) {
case 0:
if (incomingByte == HEADER1) {
ibusPacket[packetIndex++] = incomingByte;
}
break;
case 1:
if (incomingByte == HEADER2) {
ibusPacket[packetIndex++] = incomingByte;
} else {
packetIndex = 0; // Reset if header not matched
}
break;
default:
ibusPacket[packetIndex++] = incomingByte;
if (packetIndex == IBUS_PACKET_SIZE) {
// Full packet received
packetAvailable = true;
packetIndex = 0; // Reset for next packet
}
break;
}
}
}

// Function to process the received IBUS packet
bool processIBUSPacket() {
if (!packetAvailable) {
return false;
}

// Verify checksum before processing
if (!verifyChecksum()) {
packetAvailable = false;
return false; // Checksum failed, discard packet
}

// Extract channel data
camX = ibusPacket[2 + 0 * 2] | (ibusPacket[3 + 0 * 2] << 8);
camY = ibusPacket[2 + 1 * 2] | (ibusPacket[3 + 1 * 2] << 8);
moveX = ibusPacket[2 + 2 * 2] | (ibusPacket[3 + 2 * 2] << 8);
moveY = ibusPacket[2 + 3 * 2] | (ibusPacket[3 + 3 * 2] << 8);
switchA = ibusPacket[2 + 4 * 2] | (ibusPacket[3 + 4 * 2] << 8);
switchB = ibusPacket[2 + 5 * 2] | (ibusPacket[3 + 5 * 2] << 8);
switchC = ibusPacket[2 + 6 * 2] | (ibusPacket[3 + 6 * 2] << 8);
switchD = ibusPacket[2 + 7 * 2] | (ibusPacket[3 + 7 * 2] << 8);
varA = ibusPacket[2 + 8 * 2] | (ibusPacket[3 + 8 * 2] << 8);
varB = ibusPacket[2 + 9 * 2] | (ibusPacket[3 + 9 * 2] << 8);

packetAvailable = false;
return true;
}

// Getters for channel values
int getCamX() { return camX; }
int getCamY() { return camY; }
int getMoveX() { return moveX; }
int getMoveY() { return moveY; }
int getSwitchA() { return switchA; }
int getSwitchB() { return switchB; }
int getSwitchC() { return switchC; }
int getSwitchD() { return switchD; }
int getVarA() { return varA; }
int getVarB() { return varB; }

private:
HardwareSerial& serial;
uint8_t rxPin;

static const int IBUS_PACKET_SIZE = 32;
static const uint8_t HEADER1 = 0x20;
static const uint8_t HEADER2 = 0x40;

uint8_t ibusPacket[IBUS_PACKET_SIZE];
int packetIndex;
bool packetAvailable;

int camX, camY, moveX, moveY, switchA, switchB, switchC, switchD, varA, varB;

// Function to verify the checksum of the IBUS packet
bool verifyChecksum() {
uint16_t checksum = 0xFFFF;
for (int i = 0; i < IBUS_PACKET_SIZE - 2; i++) {
checksum -= ibusPacket[i];
}
uint16_t receivedChecksum = ibusPacket[IBUS_PACKET_SIZE - 2] | (ibusPacket[IBUS_PACKET_SIZE - 1] << 8);
return checksum == receivedChecksum;
}
};

// Create an instance of IBusReceiver
// For ESP32, Serial2 is typically used for UART communication
IBusReceiver ibus(Serial2, 16); // Assuming RX is on GPIO 13

void setup() {
Serial.begin(115200); // Initialize Serial Monitor
ibus.begin(115200); // Initialize IBUS receiver with baud rate 115200

// Optional: Give some time for serial ports to initialize
delay(1000);
}

void loop() {
// Read IBUS data
ibus.readIBUSData();

// Process IBUS packet if available
if (ibus.processIBUSPacket()) {
// Retrieve the channel values
int camX = ibus.getCamX();
int camY = ibus.getCamY();
int moveX = ibus.getMoveX();
int moveY = ibus.getMoveY();
int switchA = ibus.getSwitchA();
int switchB = ibus.getSwitchB();
int switchC = ibus.getSwitchC();
int switchD = ibus.getSwitchD();
int varA = ibus.getVarA();
int varB = ibus.getVarB();

// Print the channel values
Serial.print(2000);
Serial.print(" ");
Serial.print(1000);
Serial.print(" ");
Serial.print(camX);
Serial.print(" ");
Serial.print(camY);
Serial.print(" ");
Serial.print(moveX);
Serial.print(" ");
Serial.print(moveY);
Serial.print(" ");
Serial.print(switchA);
Serial.print(" ");
Serial.print(switchB);
Serial.print(" ");
Serial.print(switchC);
Serial.print(" ");
Serial.print(switchD);
Serial.print(" ");
Serial.print(varA);
Serial.print(" ");
Serial.print(varB);
Serial.println("");
}

// Optional: Add a small delay to prevent flooding the serial monitor
// delay(10);
}

Step 2: Connect Esp32 to Two BTS7960 Motor Drivers to Control Wheel Dc Motors

We are going to use some somewhat beefy but inexpensive motor drivers, one for each wheel (each of which has its own motor). The BTS7960 is rated up to 43A, which will be more than sufficient unless we live on a steep hill (in which case we would like to hear from us). 

We will assign two pins to the ESP32 to output the PWM signal using the MCPWM library (apparently it’s like, better than the LEDC library or something for reasons I don’t understand). While we are soldering, we will ask ChatGPT^ei to write up another cpp class allowing us to easily control the drivers from the main loop.

This code uses the MCPWM library to drive the BTS7960.

If this works correctly, it should look something like the first part of this:



#include "driver/mcpwm.h"

// --- IBusReceiver Class ---
class IBusReceiver {
public:
// Constructor
IBusReceiver(HardwareSerial& serialPort, uint8_t rxPin) : serial(serialPort), rxPin(rxPin) {
packetIndex = 0;
packetAvailable = false;
}

// Initialize the serial port
void begin(uint32_t baudRate) {
serial.begin(baudRate, SERIAL_8N1, rxPin, -1);
}

// Non-blocking function to read IBUS data
void readIBUSData() {
while (serial.available()) {
uint8_t incomingByte = serial.read();
switch (packetIndex) {
case 0:
if (incomingByte == HEADER1) {
ibusPacket[packetIndex++] = incomingByte;
}
break;
case 1:
if (incomingByte == HEADER2) {
ibusPacket[packetIndex++] = incomingByte;
} else {
packetIndex = 0; // Reset if header not matched
}
break;
default:
ibusPacket[packetIndex++] = incomingByte;
if (packetIndex == IBUS_PACKET_SIZE) {
// Full packet received
packetAvailable = true;
packetIndex = 0; // Reset for next packet
}
break;
}
}
}

// Function to process the received IBUS packet
bool processIBUSPacket() {
if (!packetAvailable) {
return false;
}

// Verify checksum before processing
if (!verifyChecksum()) {
packetAvailable = false;
return false; // Checksum failed, discard packet
}

// Extract channel data
camX = ibusPacket[2 + 0 * 2] | (ibusPacket[3 + 0 * 2] << 8);
camY = ibusPacket[2 + 1 * 2] | (ibusPacket[3 + 1 * 2] << 8);
moveY = ibusPacket[2 + 2 * 2] | (ibusPacket[3 + 2 * 2] << 8);
moveX = ibusPacket[2 + 3 * 2] | (ibusPacket[3 + 3 * 2] << 8);
switchA = ibusPacket[2 + 4 * 2] | (ibusPacket[3 + 4 * 2] << 8);
switchB = ibusPacket[2 + 5 * 2] | (ibusPacket[3 + 5 * 2] << 8);
switchC = ibusPacket[2 + 6 * 2] | (ibusPacket[3 + 6 * 2] << 8);
switchD = ibusPacket[2 + 7 * 2] | (ibusPacket[3 + 7 * 2] << 8);
varA = ibusPacket[2 + 8 * 2] | (ibusPacket[3 + 8 * 2] << 8);
varB = ibusPacket[2 + 9 * 2] | (ibusPacket[3 + 9 * 2] << 8);

packetAvailable = false;
return true;
}

// Getters for channel values
int getCamX() { return camX; }
int getCamY() { return camY; }
int getMoveX() { return moveX; }
int getMoveY() { return moveY; }
int getSwitchA() { return switchA; }
int getSwitchB() { return switchB; }
int getSwitchC() { return switchC; }
int getSwitchD() { return switchD; }
int getVarA() { return varA; }
int getVarB() { return varB; }

private:
HardwareSerial& serial;
uint8_t rxPin;

static const int IBUS_PACKET_SIZE = 32;
static const uint8_t HEADER1 = 0x20;
static const uint8_t HEADER2 = 0x40;

uint8_t ibusPacket[IBUS_PACKET_SIZE];
int packetIndex;
bool packetAvailable;

int camX, camY, moveX, moveY, switchA, switchB, switchC, switchD, varA, varB;

// Function to verify the checksum of the IBUS packet
bool verifyChecksum() {
uint16_t checksum = 0xFFFF;
for (int i = 0; i < IBUS_PACKET_SIZE - 2; i++) {
checksum -= ibusPacket[i];
}
uint16_t receivedChecksum = ibusPacket[IBUS_PACKET_SIZE - 2] | (ibusPacket[IBUS_PACKET_SIZE - 1] << 8);
return checksum == receivedChecksum;
}
};

// --- MyMotor Class ---
class MyMotor {
public:
// Constructor: Initializes MCPWM and sets up pins
MyMotor(int forwardPin, int reversePin) {
this->forwardPin = forwardPin;
this->reversePin = reversePin;

// Initialize MCPWM for forward and reverse pins
mcpwm_gpio_init(MCPWM_UNIT_0, MCPWM0A, forwardPin); // Initialize forward pin for PWM
mcpwm_gpio_init(MCPWM_UNIT_0, MCPWM0B, reversePin); // Initialize reverse pin for PWM

// Configure MCPWM unit 0, timer 0 for both forward and reverse control
mcpwm_config_t pwm_config;
pwm_config.frequency = 1000; // Set frequency to 1 kHz
pwm_config.cmpr_a = 0.0; // Initial duty cycle of PWMxA = 0%
pwm_config.cmpr_b = 0.0; // Initial duty cycle of PWMxB = 0%
pwm_config.counter_mode = MCPWM_UP_COUNTER; // Up counting mode
pwm_config.duty_mode = MCPWM_DUTY_MODE_0; // Active high duty

// Initialize MCPWM with the above configuration
mcpwm_init(MCPWM_UNIT_0, MCPWM_TIMER_0, &pwm_config);
}

// Method to set speed and direction: -100 to 100
void setSpeed(int speed) {
if (speed < -100) speed = -100; // Limit speed to minimum -100
if (speed > 100) speed = 100; // Limit speed to maximum 100

// Calculate the absolute duty cycle percentage (0 to 100)
float dutyCycle = abs(speed);

// Forward direction
if (speed > 0) {
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, dutyCycle); // Set duty for forward
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_B, 0.0); // Ensure reverse is 0
Serial.print("Forward Speed: ");
}
// Reverse direction
else if (speed < 0) {
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, 0.0); // Ensure forward is 0
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_B, dutyCycle); // Set duty for reverse
Serial.print("Reverse Speed: ");
}
// Stop
else {
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, 0.0); // Stop forward
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_B, 0.0); // Stop reverse
Serial.println("Motor Stopped");
return;
}

// Print the set speed to Serial Monitor
Serial.print(speed);
Serial.println("%");
}

private:
int forwardPin; // GPIO for forward PWM control
int reversePin; // GPIO for reverse PWM control
};

// --- Global Instances ---

// Create an instance of IBusReceiver
// For ESP32, Serial2 is typically used for UART communication
IBusReceiver ibus(Serial2, 16); // Assuming RX is on GPIO 16

// Create an instance of the MyMotor class
MyMotor motor(4, 15); // Use GPIO 4 for forward, GPIO 15 for reverse

void setup() {
Serial.begin(115200); // Initialize Serial Monitor
ibus.begin(115200); // Initialize IBUS receiver with baud rate 115200

Serial.println("Starting Motor Control with iBUS Receiver...");

// Optional: Give some time for serial ports to initialize
delay(1000);
}

void loop() {
// Read IBUS data
ibus.readIBUSData();

// Process IBUS packet if available
if (ibus.processIBUSPacket()) {
// Retrieve the moveY channel value
int moveY = ibus.getMoveY();

// Map moveY from 1000-2000 to -100 to 100
int motorSpeed = map(moveY, 1000, 2000, -100, 100);

// Set motor speed
motor.setSpeed(motorSpeed);

// Optionally, print the values for debugging
Serial.print("moveY: ");
Serial.print(moveY);
Serial.print(" mapped to motorSpeed: ");
Serial.println(motorSpeed);
}
}

Step 3: The Steering Pulley

Dear reader, you’re on your own for the mechanical part of this section; I was entirely out of my depth here. I’m not exaggerating when I say took four iterations of progressively more (poor) aluminum TIG welding to make something rigid where I could have it fully control the steering wheel. I know some smart people hang out here and might have a better idea, please let me know as this was the best that I can come up with (including with advice from people who know what they are talking about).

Long story short, we will use a 20-tooth GT2 pulley on a 35kg*cm servo to drive a 40-tooth pulley mounted to the driving shaft. It bears stating, Power Wheels do not use elegant mechanisms for their steering.

Once we make our servo-drive-shaft coupling rigid (this involved more welding and fabricating it out tensioner for me, but I hope you understand this better), let's take our current existing code and add include the servo controlling the steering wheel.

Now that we have both the motor drivers and the steering servo controlled by the Esp32, we can do things like this:


#include "driver/mcpwm.h"

// --- IBusReceiver Class ---
class IBusReceiver {
public:
// Constructor
IBusReceiver(HardwareSerial& serialPort, uint8_t rxPin) : serial(serialPort), rxPin(rxPin) {
packetIndex = 0;
packetAvailable = false;
}

// Initialize the serial port
void begin(uint32_t baudRate) {
serial.begin(baudRate, SERIAL_8N1, rxPin, -1);
}

// Non-blocking function to read IBUS data
void readIBUSData() {
while (serial.available()) {
uint8_t incomingByte = serial.read();
switch (packetIndex) {
case 0:
if (incomingByte == HEADER1) {
ibusPacket[packetIndex++] = incomingByte;
}
break;
case 1:
if (incomingByte == HEADER2) {
ibusPacket[packetIndex++] = incomingByte;
} else {
packetIndex = 0; // Reset if header not matched
}
break;
default:
ibusPacket[packetIndex++] = incomingByte;
if (packetIndex == IBUS_PACKET_SIZE) {
// Full packet received
packetAvailable = true;
packetIndex = 0; // Reset for next packet
}
break;
}
}
}

// Function to process the received IBUS packet
bool processIBUSPacket() {
if (!packetAvailable) {
return false;
}

// Verify checksum before processing
if (!verifyChecksum()) {
packetAvailable = false;
return false; // Checksum failed, discard packet
}

// Extract channel data
camX = ibusPacket[2 + 0 * 2] | (ibusPacket[3 + 0 * 2] << 8);
camY = ibusPacket[2 + 1 * 2] | (ibusPacket[3 + 1 * 2] << 8);
moveY = ibusPacket[2 + 2 * 2] | (ibusPacket[3 + 2 * 2] << 8);
moveX = ibusPacket[2 + 3 * 2] | (ibusPacket[3 + 3 * 2] << 8);
switchA = ibusPacket[2 + 4 * 2] | (ibusPacket[3 + 4 * 2] << 8);
switchB = ibusPacket[2 + 5 * 2] | (ibusPacket[3 + 5 * 2] << 8);
switchC = ibusPacket[2 + 6 * 2] | (ibusPacket[3 + 6 * 2] << 8);
switchD = ibusPacket[2 + 7 * 2] | (ibusPacket[3 + 7 * 2] << 8);
varA = ibusPacket[2 + 8 * 2] | (ibusPacket[3 + 8 * 2] << 8);
varB = ibusPacket[2 + 9 * 2] | (ibusPacket[3 + 9 * 2] << 8);

packetAvailable = false;
return true;
}

// Getters for channel values
int getCamX() { return camX; }
int getCamY() { return camY; }
int getMoveX() { return moveX; }
int getMoveY() { return moveY; }
int getSwitchA() { return switchA; }
int getSwitchB() { return switchB; }
int getSwitchC() { return switchC; }
int getSwitchD() { return switchD; }
int getVarA() { return varA; }
int getVarB() { return varB; }

private:
HardwareSerial& serial;
uint8_t rxPin;

static const int IBUS_PACKET_SIZE = 32;
static const uint8_t HEADER1 = 0x20;
static const uint8_t HEADER2 = 0x40;

uint8_t ibusPacket[IBUS_PACKET_SIZE];
int packetIndex;
bool packetAvailable;

int camX, camY, moveX, moveY, switchA, switchB, switchC, switchD, varA, varB;

// Function to verify the checksum of the IBUS packet
bool verifyChecksum() {
uint16_t checksum = 0xFFFF;
for (int i = 0; i < IBUS_PACKET_SIZE - 2; i++) {
checksum -= ibusPacket[i];
}
uint16_t receivedChecksum = ibusPacket[IBUS_PACKET_SIZE - 2] | (ibusPacket[IBUS_PACKET_SIZE - 1] << 8);
return checksum == receivedChecksum;
}
};

// --- MyMotor Class ---
class MyMotor {
public:
// Constructor: Initializes MCPWM and sets up pins
MyMotor(int forwardPin, int reversePin) {
this->forwardPin = forwardPin;
this->reversePin = reversePin;

// Initialize MCPWM for forward and reverse pins
mcpwm_gpio_init(MCPWM_UNIT_0, MCPWM0A, forwardPin); // Initialize forward pin for PWM
mcpwm_gpio_init(MCPWM_UNIT_0, MCPWM0B, reversePin); // Initialize reverse pin for PWM

// Configure MCPWM unit 0, timer 0 for both forward and reverse control
mcpwm_config_t pwm_config;
pwm_config.frequency = 1000; // Set frequency to 1 kHz
pwm_config.cmpr_a = 0.0; // Initial duty cycle of PWMxA = 0%
pwm_config.cmpr_b = 0.0; // Initial duty cycle of PWMxB = 0%
pwm_config.counter_mode = MCPWM_UP_COUNTER; // Up counting mode
pwm_config.duty_mode = MCPWM_DUTY_MODE_0; // Active high duty

// Initialize MCPWM with the above configuration
mcpwm_init(MCPWM_UNIT_0, MCPWM_TIMER_0, &pwm_config);
}

// Method to set speed and direction: -100 to 100
void setSpeed(int speed) {
if (speed < -100) speed = -100; // Limit speed to minimum -100
if (speed > 100) speed = 100; // Limit speed to maximum 100

// Calculate the absolute duty cycle percentage (0 to 100)
float dutyCycle = abs(speed);

// Forward direction
if (speed > 0) {
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, dutyCycle); // Set duty for forward
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_B, 0.0); // Ensure reverse is 0
//Serial.print("Forward Speed: ");
}
// Reverse direction
else if (speed < 0) {
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, 0.0); // Ensure forward is 0
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_B, dutyCycle); // Set duty for reverse
//Serial.print("Reverse Speed: ");
}
// Stop
else {
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, 0.0); // Stop forward
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_B, 0.0); // Stop reverse
//Serial.println("Motor Stopped");
return;
}

// Print the set speed to Serial Monitor
Serial.print(speed);
//Serial.println("%");
}

private:
int forwardPin; // GPIO for forward PWM control
int reversePin; // GPIO for reverse PWM control
};

// --- MyServo Class ---
class MyServo {
public:
// Constructor to initialize the servo on a given MCPWM unit, timer, operator, and GPIO pin
MyServo(mcpwm_unit_t unit, mcpwm_timer_t timer, mcpwm_operator_t op, int gpio_pin) {
mcpwm_num = unit;
timer_num = timer;
op_num = op;
gpio_num = gpio_pin;

// Initialize MCPWM GPIO pin
mcpwm_gpio_init(mcpwm_num, getSignal(), gpio_num);

// Configure MCPWM unit
mcpwm_config_t pwm_config;
pwm_config.frequency = 50; // Frequency = 50Hz for servo
pwm_config.cmpr_a = 0; // Duty cycle for PWMxA = 0
pwm_config.cmpr_b = 0; // Duty cycle for PWMxB = 0
pwm_config.counter_mode = MCPWM_UP_COUNTER;
pwm_config.duty_mode = MCPWM_DUTY_MODE_0;

// Initialize MCPWM with the configuration
mcpwm_init(mcpwm_num, timer_num, &pwm_config);
}

// Method to set the servo angle (75 to 195 degrees)
void setAngle(int angle) {
float duty_cycle = calculateDutyCycle(angle);
mcpwm_set_duty(mcpwm_num, timer_num, op_num, duty_cycle);
mcpwm_set_duty_type(mcpwm_num, timer_num, op_num, MCPWM_DUTY_MODE_0);
}

private:
mcpwm_unit_t mcpwm_num; // MCPWM unit number
mcpwm_timer_t timer_num; // MCPWM timer number
mcpwm_operator_t op_num; // MCPWM operator (A or B)
int gpio_num; // GPIO pin number

// Function to calculate duty cycle based on the desired angle
float calculateDutyCycle(int angle) {
// Pulse width range from 0.5ms (0 degrees) to 2.5ms (270 degrees)
float min_pulse_width = 0.5; // Minimum pulse width in milliseconds
float max_pulse_width = 2.5; // Maximum pulse width in milliseconds
float frequency = 50.0; // Frequency in Hz
float period = 1000.0 / frequency; // Period in milliseconds (20ms)

// Ensure the angle is within the specified range
if (angle < 75) angle = 75;
if (angle > 195) angle = 195;

// Calculate pulse width for the given angle
float pulse_width = min_pulse_width + ((float)angle / 270.0) * (max_pulse_width - min_pulse_width);

// Calculate duty cycle percentage
float duty_cycle = (pulse_width / period) * 100.0;

return duty_cycle;
}

// Helper function to get the correct MCPWM signal for GPIO initialization
mcpwm_io_signals_t getSignal() {
if (op_num == MCPWM_OPR_A) {
if (timer_num == MCPWM_TIMER_0) return MCPWM0A;
else if (timer_num == MCPWM_TIMER_1) return MCPWM1A;
else return MCPWM2A; // MCPWM_TIMER_2
} else {
if (timer_num == MCPWM_TIMER_0) return MCPWM0B;
else if (timer_num == MCPWM_TIMER_1) return MCPWM1B;
else return MCPWM2B; // MCPWM_TIMER_2
}
}
};

// --- Global Instances ---

// Create an instance of IBusReceiver
// For ESP32, Serial2 is typically used for UART communication
IBusReceiver ibus(Serial2, 16); // Assuming RX is on GPIO 16

// Create an instance of the MyMotor class
MyMotor motor(4, 15); // Use GPIO 4 for forward, GPIO 15 for reverse

// Create an instance of the MyServo class
MyServo servo_wheel(MCPWM_UNIT_1, MCPWM_TIMER_0, MCPWM_OPR_A, 25); // Servo on GPIO25

void setup() {
Serial.begin(115200); // Initialize Serial Monitor
ibus.begin(115200); // Initialize IBUS receiver with baud rate 115200

Serial.println("Starting Motor and Servo Control with iBUS Receiver...");

// Optional: Give some time for serial ports to initialize
delay(1000);
}

void loop() {
// Read IBUS data
ibus.readIBUSData();

// Process IBUS packet if available
if (ibus.processIBUSPacket()) {
// Retrieve the moveY and moveX channel values
int moveY = ibus.getMoveY();
int moveX = ibus.getMoveX();
int camX = ibus.getCamX();
int camY = ibus.getCamY();

// Map moveY from 1000-2000 to -100 to 100
int motorSpeed = map(moveY, 1000, 2000, -100, 100);
motor.setSpeed(motorSpeed);

// Map moveX from 1000-2000 to 75-195 degrees
int servo_wheel_angle = map(moveX, 1000, 2000, 80, 190);
servo_wheel.setAngle(servo_wheel_angle);

// Map camX from 1000-2000 to whatever
int servo_pan_angle = map(camX, 1000, 2000, 45, 225);
servo_wheel.setAngle(servo_pan_angle);
int servo_tilt_angle = map(camX, 1000, 2000, 45, 225);
servo_wheel.setAngle(servo_tilt_angle);

Serial.print(motorSpeed);
Serial.print(" ");
Serial.print(servo_wheel_angle);
Serial.print(" ");
Serial.print(servo_pan_angle);
Serial.print(" ");
Serial.println(servo_tilt_angle);
}
}

Step 4: Pan and Tilt Skull Servos

I imagine this will be different for each of our personal skills, skill sets, and available materials. This probably could have been CAD'd and 3d printed, but I wanted to be away from the computer for a bit, so I made a weird mechanism for the pan and tilt functions of the skull.


In any event, here is a code with the motor driver is going, the steering wheel servo going, and both the pan and the tilt servers going (if you want to see videos on the actual wiring of stuff let you know because I definitely already have the footage):


#include "driver/mcpwm.h"

// --- IBusReceiver Class ---
class IBusReceiver {
public:
// Constructor
IBusReceiver(HardwareSerial& serialPort, uint8_t rxPin) : serial(serialPort), rxPin(rxPin) {
packetIndex = 0;
packetAvailable = false;
}

// Initialize the serial port
void begin(uint32_t baudRate) {
serial.begin(baudRate, SERIAL_8N1, rxPin, -1);
}

// Non-blocking function to read IBUS data
void readIBUSData() {
while (serial.available()) {
uint8_t incomingByte = serial.read();
switch (packetIndex) {
case 0:
if (incomingByte == HEADER1) {
ibusPacket[packetIndex++] = incomingByte;
}
break;
case 1:
if (incomingByte == HEADER2) {
ibusPacket[packetIndex++] = incomingByte;
} else {
packetIndex = 0; // Reset if header not matched
}
break;
default:
ibusPacket[packetIndex++] = incomingByte;
if (packetIndex == IBUS_PACKET_SIZE) {
// Full packet received
packetAvailable = true;
packetIndex = 0; // Reset for next packet
}
break;
}
}
}

// Function to process the received IBUS packet
bool processIBUSPacket() {
if (!packetAvailable) {
return false;
}

// Verify checksum before processing
if (!verifyChecksum()) {
packetAvailable = false;
return false; // Checksum failed, discard packet
}

// Extract channel data
camX = ibusPacket[2 + 0 * 2] | (ibusPacket[3 + 0 * 2] << 8);
camY = ibusPacket[2 + 1 * 2] | (ibusPacket[3 + 1 * 2] << 8);
moveY = ibusPacket[2 + 2 * 2] | (ibusPacket[3 + 2 * 2] << 8);
moveX = ibusPacket[2 + 3 * 2] | (ibusPacket[3 + 3 * 2] << 8);
switchA = ibusPacket[2 + 4 * 2] | (ibusPacket[3 + 4 * 2] << 8);
switchB = ibusPacket[2 + 5 * 2] | (ibusPacket[3 + 5 * 2] << 8);
switchC = ibusPacket[2 + 6 * 2] | (ibusPacket[3 + 6 * 2] << 8);
switchD = ibusPacket[2 + 7 * 2] | (ibusPacket[3 + 7 * 2] << 8);
varA = ibusPacket[2 + 8 * 2] | (ibusPacket[3 + 8 * 2] << 8);
varB = ibusPacket[2 + 9 * 2] | (ibusPacket[3 + 9 * 2] << 8);

packetAvailable = false;
return true;
}

// Getters for channel values
int getCamX() { return camX; }
int getCamY() { return camY; }
int getMoveX() { return moveX; }
int getMoveY() { return moveY; }
int getSwitchA() { return switchA; }
int getSwitchB() { return switchB; }
int getSwitchC() { return switchC; }
int getSwitchD() { return switchD; }
int getVarA() { return varA; }
int getVarB() { return varB; }

private:
HardwareSerial& serial;
uint8_t rxPin;

static const int IBUS_PACKET_SIZE = 32;
static const uint8_t HEADER1 = 0x20;
static const uint8_t HEADER2 = 0x40;

uint8_t ibusPacket[IBUS_PACKET_SIZE];
int packetIndex;
bool packetAvailable;

int camX, camY, moveX, moveY, switchA, switchB, switchC, switchD, varA, varB;

// Function to verify the checksum of the IBUS packet
bool verifyChecksum() {
uint16_t checksum = 0xFFFF;
for (int i = 0; i < IBUS_PACKET_SIZE - 2; i++) {
checksum -= ibusPacket[i];
}
uint16_t receivedChecksum = ibusPacket[IBUS_PACKET_SIZE - 2] | (ibusPacket[IBUS_PACKET_SIZE - 1] << 8);
return checksum == receivedChecksum;
}
};

// --- MyMotor Class ---
class MyMotor {
public:
// Constructor: Initializes MCPWM and sets up pins
MyMotor(int forwardPin, int reversePin) {
this->forwardPin = forwardPin;
this->reversePin = reversePin;

// Initialize MCPWM for forward and reverse pins
mcpwm_gpio_init(MCPWM_UNIT_0, MCPWM0A, forwardPin); // Initialize forward pin for PWM
mcpwm_gpio_init(MCPWM_UNIT_0, MCPWM0B, reversePin); // Initialize reverse pin for PWM

// Configure MCPWM unit 0, timer 0 for both forward and reverse control
mcpwm_config_t pwm_config;
pwm_config.frequency = 1000; // Set frequency to 1 kHz
pwm_config.cmpr_a = 0.0; // Initial duty cycle of PWMxA = 0%
pwm_config.cmpr_b = 0.0; // Initial duty cycle of PWMxB = 0%
pwm_config.counter_mode = MCPWM_UP_COUNTER; // Up counting mode
pwm_config.duty_mode = MCPWM_DUTY_MODE_0; // Active high duty

// Initialize MCPWM with the above configuration
mcpwm_init(MCPWM_UNIT_0, MCPWM_TIMER_0, &pwm_config);
}

// Method to set speed and direction: -100 to 100
void setSpeed(int speed) {
if (speed < -100) speed = -100; // Limit speed to minimum -100
if (speed > 100) speed = 100; // Limit speed to maximum 100

// Calculate the absolute duty cycle percentage (0 to 100)
float dutyCycle = abs(speed);

// Forward direction
if (speed > 0) {
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, dutyCycle); // Set duty for forward
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_B, 0.0); // Ensure reverse is 0
//Serial.print("Forward Speed: ");
}
// Reverse direction
else if (speed < 0) {
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, 0.0); // Ensure forward is 0
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_B, dutyCycle); // Set duty for reverse
//Serial.print("Reverse Speed: ");
}
// Stop
else {
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, 0.0); // Stop forward
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_B, 0.0); // Stop reverse
//Serial.println("Motor Stopped");
return;
}

// Print the set speed to Serial Monitor
//Serial.print(speed);
//Serial.println("%");
}

private:
int forwardPin; // GPIO for forward PWM control
int reversePin; // GPIO for reverse PWM control
};

// --- MyServo Class ---
class MyServo {
public:
// Constructor to initialize the servo on a given MCPWM unit, timer, operator, and GPIO pin
MyServo(mcpwm_unit_t unit, mcpwm_timer_t timer, mcpwm_operator_t op, int gpio_pin) {
mcpwm_num = unit;
timer_num = timer;
op_num = op;
gpio_num = gpio_pin;

// Initialize MCPWM GPIO pin
mcpwm_gpio_init(mcpwm_num, getSignal(), gpio_num);

// Configure MCPWM unit
mcpwm_config_t pwm_config;
pwm_config.frequency = 50; // Frequency = 50Hz for servo
pwm_config.cmpr_a = 0; // Duty cycle for PWMxA = 0
pwm_config.cmpr_b = 0; // Duty cycle for PWMxB = 0
pwm_config.counter_mode = MCPWM_UP_COUNTER;
pwm_config.duty_mode = MCPWM_DUTY_MODE_0;

// Initialize MCPWM with the configuration
mcpwm_init(mcpwm_num, timer_num, &pwm_config);
}

// Method to set the servo angle (75 to 195 degrees)
void setAngle(int angle) {
float duty_cycle = calculateDutyCycle(angle);
mcpwm_set_duty(mcpwm_num, timer_num, op_num, duty_cycle);
mcpwm_set_duty_type(mcpwm_num, timer_num, op_num, MCPWM_DUTY_MODE_0);
}

private:
mcpwm_unit_t mcpwm_num; // MCPWM unit number
mcpwm_timer_t timer_num; // MCPWM timer number
mcpwm_operator_t op_num; // MCPWM operator (A or B)
int gpio_num; // GPIO pin number

// Function to calculate duty cycle based on the desired angle
float calculateDutyCycle(int angle) {
// Pulse width range from 0.5ms (0 degrees) to 2.5ms (270 degrees)
float min_pulse_width = 0.5; // Minimum pulse width in milliseconds
float max_pulse_width = 2.5; // Maximum pulse width in milliseconds
float frequency = 50.0; // Frequency in Hz
float period = 1000.0 / frequency; // Period in milliseconds (20ms)

// Ensure the angle is within the specified range
if (angle < 75) angle = 75;
if (angle > 195) angle = 195;

// Calculate pulse width for the given angle
float pulse_width = min_pulse_width + ((float)angle / 270.0) * (max_pulse_width - min_pulse_width);

// Calculate duty cycle percentage
float duty_cycle = (pulse_width / period) * 100.0;

return duty_cycle;
}

// Helper function to get the correct MCPWM signal for GPIO initialization
mcpwm_io_signals_t getSignal() {
if (op_num == MCPWM_OPR_A) {
if (timer_num == MCPWM_TIMER_0) return MCPWM0A;
else if (timer_num == MCPWM_TIMER_1) return MCPWM1A;
else return MCPWM2A; // MCPWM_TIMER_2
} else {
if (timer_num == MCPWM_TIMER_0) return MCPWM0B;
else if (timer_num == MCPWM_TIMER_1) return MCPWM1B;
else return MCPWM2B; // MCPWM_TIMER_2
}
}
};

// --- Global Instances ---

// Create an instance of IBusReceiver
// For ESP32, Serial2 is typically used for UART communication
IBusReceiver ibus(Serial2, 16); // Assuming RX is on GPIO 16

// Create an instance of the MyMotor class
MyMotor motor(4, 15); // Use GPIO 4 for forward, GPIO 15 for reverse

// Create instances of the MyServo class
MyServo servo_wheel(MCPWM_UNIT_1, MCPWM_TIMER_0, MCPWM_OPR_A, 25); // Servo on GPIO25
MyServo servo_pan(MCPWM_UNIT_1, MCPWM_TIMER_1, MCPWM_OPR_A, 26); // Servo on GPIO26
MyServo servo_tilt(MCPWM_UNIT_1, MCPWM_TIMER_2, MCPWM_OPR_A, 27); // Servo on GPIO27

// Define GPIO pins for the relays
const int propane_relay = 18; // GPIO 18 controls the propane relay
const int arc_relay = 19; // GPIO 19 controls the arc relay

int tiltMap(int input) {
int output;
// First linear relationship: from input 1060 to 1500, output 181 to 157
if (input <= 1500) {
output = map(input, 1060, 1500, 181, 157);
}
// Second linear relationship: from input 1500 to 2000, output 157 to 100
else {
output = map(input, 1500, 2000, 157, 100);
}
return output;
}

void setup() {
Serial.begin(115200); // Initialize Serial Monitor
ibus.begin(115200); // Initialize IBUS receiver with baud rate 115200

Serial.println("Starting Motor and Servo Control with iBUS Receiver...");

// Initialize relay control pins
pinMode(propane_relay, OUTPUT);
pinMode(arc_relay, OUTPUT);

// Ensure both relays are OFF at startup
digitalWrite(propane_relay, LOW);
digitalWrite(arc_relay, LOW);

// Optional: Give some time for serial ports to initialize
delay(1000);
}

void loop() {
// Read IBUS data
ibus.readIBUSData();

// Process IBUS packet if available
if (ibus.processIBUSPacket()) {
// Retrieve the moveY, moveX, camX, camY, and switchC channel values
int moveY = ibus.getMoveY();
int moveX = ibus.getMoveX();
int camX = ibus.getCamX();
int camY = ibus.getCamY();
int switchC = ibus.getSwitchC();

// Map moveY from 1000-2000 to -100 to 100
int motorSpeed = map(moveY, 1000, 2000, -100, 100);
motor.setSpeed(motorSpeed);

// Map moveX from 1000-2000 to 80-190 degrees
int servo_wheel_angle = map(moveX, 1000, 2000, 190, 80);
servo_wheel.setAngle(servo_wheel_angle);

// Map camX and camY for pan and tilt servos
int servo_pan_angle = map(camX, 1044, 1946, 215, 55);
servo_pan.setAngle(servo_pan_angle);
int servo_tilt_angle = tiltMap(camY);
servo_tilt.setAngle(servo_tilt_angle);

// Control relays based on switchC value
if (switchC == 1000) {
// Both relays OFF
digitalWrite(propane_relay, LOW);
digitalWrite(arc_relay, LOW);
} else if (switchC == 1500) {
// Propane relay ON, Arc relay OFF
digitalWrite(propane_relay, HIGH);
digitalWrite(arc_relay, LOW);
} else if (switchC == 2000) {
// Both relays ON
digitalWrite(propane_relay, HIGH);
digitalWrite(arc_relay, HIGH);
}

// Debugging output
/*
Serial.print("motorSpeed: ");
Serial.print(motorSpeed);
Serial.print(", servo_wheel_angle: ");
Serial.print(servo_wheel_angle);
Serial.print(", servo_pan_angle: ");
Serial.print(servo_pan_angle);
Serial.print(", servo_tilt_angle: ");
Serial.print(servo_tilt_angle);
Serial.print(", switchC: ");
Serial.println(switchC);
*/
}
}

Step 5: Make It Like Ghost Rider

Please, please don't make this your first experiment with flammable gas. There, I'm prepared for the onslaught of the Internet safety nerds. But seriously, this is not the starter project for flammable gas.

That said, let's plumb some propane into little Timmy to make him more awesome and bad ass! Visual aids are in the full video, but basically we do a plane cut through the top of the skull and weld some ~1/8" mild steel to it. I dunno if the skull is truly "cast iron," as that's outside my territory. All I know is it welded to mild steel really nicely! We bevel and cut a 1/4" NPT male threaded pipe (aka "nipple") to the bottom of the skull, then we drill out some holes and screw in a propane line. Control that propane with a male-female 1/4" NPT solenoid valve connected to a relay connected to the esp32.

Last thing to do? We connect another relay, then connect a high-voltage trasformer for the electrical/plasma arc. At this point, the left steering stick controls the speed/ direction, the right steering stick controls the head position, and switch3 (a 3-way switch) controls both the propane and the ignition. Position 0: all off // Positon 1: gas solenoid on, ignition off // Position 2: gas solenoid and ignition on


#include "driver/mcpwm.h"

// --- IBusReceiver Class ---
class IBusReceiver {
public:
// Constructor
IBusReceiver(HardwareSerial& serialPort, uint8_t rxPin) : serial(serialPort), rxPin(rxPin) {
packetIndex = 0;
packetAvailable = false;
}

// Initialize the serial port
void begin(uint32_t baudRate) {
serial.begin(baudRate, SERIAL_8N1, rxPin, -1);
}

// Non-blocking function to read IBUS data
void readIBUSData() {
while (serial.available()) {
uint8_t incomingByte = serial.read();
switch (packetIndex) {
case 0:
if (incomingByte == HEADER1) {
ibusPacket[packetIndex++] = incomingByte;
}
break;
case 1:
if (incomingByte == HEADER2) {
ibusPacket[packetIndex++] = incomingByte;
} else {
packetIndex = 0; // Reset if header not matched
}
break;
default:
ibusPacket[packetIndex++] = incomingByte;
if (packetIndex == IBUS_PACKET_SIZE) {
// Full packet received
packetAvailable = true;
packetIndex = 0; // Reset for next packet
}
break;
}
}
}

// Function to process the received IBUS packet
bool processIBUSPacket() {
if (!packetAvailable) {
return false;
}

// Verify checksum before processing
if (!verifyChecksum()) {
packetAvailable = false;
return false; // Checksum failed, discard packet
}

// Extract channel data
camX = ibusPacket[2 + 0 * 2] | (ibusPacket[3 + 0 * 2] << 8);
camY = ibusPacket[2 + 1 * 2] | (ibusPacket[3 + 1 * 2] << 8);
moveY = ibusPacket[2 + 2 * 2] | (ibusPacket[3 + 2 * 2] << 8);
moveX = ibusPacket[2 + 3 * 2] | (ibusPacket[3 + 3 * 2] << 8);
switchA = ibusPacket[2 + 4 * 2] | (ibusPacket[3 + 4 * 2] << 8);
switchB = ibusPacket[2 + 5 * 2] | (ibusPacket[3 + 5 * 2] << 8);
switchC = ibusPacket[2 + 6 * 2] | (ibusPacket[3 + 6 * 2] << 8);
switchD = ibusPacket[2 + 7 * 2] | (ibusPacket[3 + 7 * 2] << 8);
varA = ibusPacket[2 + 8 * 2] | (ibusPacket[3 + 8 * 2] << 8);
varB = ibusPacket[2 + 9 * 2] | (ibusPacket[3 + 9 * 2] << 8);

packetAvailable = false;
return true;
}

// Getters for channel values
int getCamX() { return camX; }
int getCamY() { return camY; }
int getMoveX() { return moveX; }
int getMoveY() { return moveY; }
int getSwitchA() { return switchA; }
int getSwitchB() { return switchB; }
int getSwitchC() { return switchC; }
int getSwitchD() { return switchD; }
int getVarA() { return varA; }
int getVarB() { return varB; }

private:
HardwareSerial& serial;
uint8_t rxPin;

static const int IBUS_PACKET_SIZE = 32;
static const uint8_t HEADER1 = 0x20;
static const uint8_t HEADER2 = 0x40;

uint8_t ibusPacket[IBUS_PACKET_SIZE];
int packetIndex;
bool packetAvailable;

int camX, camY, moveX, moveY, switchA, switchB, switchC, switchD, varA, varB;

// Function to verify the checksum of the IBUS packet
bool verifyChecksum() {
uint16_t checksum = 0xFFFF;
for (int i = 0; i < IBUS_PACKET_SIZE - 2; i++) {
checksum -= ibusPacket[i];
}
uint16_t receivedChecksum = ibusPacket[IBUS_PACKET_SIZE - 2] | (ibusPacket[IBUS_PACKET_SIZE - 1] << 8);
return checksum == receivedChecksum;
}
};

// --- MyMotor Class ---
class MyMotor {
public:
// Constructor: Initializes MCPWM and sets up pins
MyMotor(int forwardPin, int reversePin) {
this->forwardPin = forwardPin;
this->reversePin = reversePin;

// Initialize MCPWM for forward and reverse pins
mcpwm_gpio_init(MCPWM_UNIT_0, MCPWM0A, forwardPin); // Initialize forward pin for PWM
mcpwm_gpio_init(MCPWM_UNIT_0, MCPWM0B, reversePin); // Initialize reverse pin for PWM

// Configure MCPWM unit 0, timer 0 for both forward and reverse control
mcpwm_config_t pwm_config;
pwm_config.frequency = 1000; // Set frequency to 1 kHz
pwm_config.cmpr_a = 0.0; // Initial duty cycle of PWMxA = 0%
pwm_config.cmpr_b = 0.0; // Initial duty cycle of PWMxB = 0%
pwm_config.counter_mode = MCPWM_UP_COUNTER; // Up counting mode
pwm_config.duty_mode = MCPWM_DUTY_MODE_0; // Active high duty

// Initialize MCPWM with the above configuration
mcpwm_init(MCPWM_UNIT_0, MCPWM_TIMER_0, &pwm_config);
}

// Method to set speed and direction: -100 to 100
void setSpeed(int speed) {
if (speed < -100) speed = -100; // Limit speed to minimum -100
if (speed > 100) speed = 100; // Limit speed to maximum 100

// Calculate the absolute duty cycle percentage (0 to 100)
float dutyCycle = abs(speed);

// Forward direction
if (speed > 0) {
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, dutyCycle); // Set duty for forward
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_B, 0.0); // Ensure reverse is 0
//Serial.print("Forward Speed: ");
}
// Reverse direction
else if (speed < 0) {
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, 0.0); // Ensure forward is 0
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_B, dutyCycle); // Set duty for reverse
//Serial.print("Reverse Speed: ");
}
// Stop
else {
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_A, 0.0); // Stop forward
mcpwm_set_duty(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_OPR_B, 0.0); // Stop reverse
//Serial.println("Motor Stopped");
return;
}

// Print the set speed to Serial Monitor
//Serial.print(speed);
//Serial.println("%");
}

private:
int forwardPin; // GPIO for forward PWM control
int reversePin; // GPIO for reverse PWM control
};

// --- MyServo Class ---
class MyServo {
public:
// Constructor to initialize the servo on a given MCPWM unit, timer, operator, and GPIO pin
MyServo(mcpwm_unit_t unit, mcpwm_timer_t timer, mcpwm_operator_t op, int gpio_pin) {
mcpwm_num = unit;
timer_num = timer;
op_num = op;
gpio_num = gpio_pin;

// Initialize MCPWM GPIO pin
mcpwm_gpio_init(mcpwm_num, getSignal(), gpio_num);

// Configure MCPWM unit
mcpwm_config_t pwm_config;
pwm_config.frequency = 50; // Frequency = 50Hz for servo
pwm_config.cmpr_a = 0; // Duty cycle for PWMxA = 0
pwm_config.cmpr_b = 0; // Duty cycle for PWMxB = 0
pwm_config.counter_mode = MCPWM_UP_COUNTER;
pwm_config.duty_mode = MCPWM_DUTY_MODE_0;

// Initialize MCPWM with the configuration
mcpwm_init(mcpwm_num, timer_num, &pwm_config);
}

// Method to set the servo angle (75 to 195 degrees)
void setAngle(int angle) {
float duty_cycle = calculateDutyCycle(angle);
mcpwm_set_duty(mcpwm_num, timer_num, op_num, duty_cycle);
mcpwm_set_duty_type(mcpwm_num, timer_num, op_num, MCPWM_DUTY_MODE_0);
}

private:
mcpwm_unit_t mcpwm_num; // MCPWM unit number
mcpwm_timer_t timer_num; // MCPWM timer number
mcpwm_operator_t op_num; // MCPWM operator (A or B)
int gpio_num; // GPIO pin number

// Function to calculate duty cycle based on the desired angle
float calculateDutyCycle(int angle) {
// Pulse width range from 0.5ms (0 degrees) to 2.5ms (270 degrees)
float min_pulse_width = 0.5; // Minimum pulse width in milliseconds
float max_pulse_width = 2.5; // Maximum pulse width in milliseconds
float frequency = 50.0; // Frequency in Hz
float period = 1000.0 / frequency; // Period in milliseconds (20ms)

// Ensure the angle is within the specified range
if (angle < 75) angle = 75;
if (angle > 195) angle = 195;

// Calculate pulse width for the given angle
float pulse_width = min_pulse_width + ((float)angle / 270.0) * (max_pulse_width - min_pulse_width);

// Calculate duty cycle percentage
float duty_cycle = (pulse_width / period) * 100.0;

return duty_cycle;
}

// Helper function to get the correct MCPWM signal for GPIO initialization
mcpwm_io_signals_t getSignal() {
if (op_num == MCPWM_OPR_A) {
if (timer_num == MCPWM_TIMER_0) return MCPWM0A;
else if (timer_num == MCPWM_TIMER_1) return MCPWM1A;
else return MCPWM2A; // MCPWM_TIMER_2
} else {
if (timer_num == MCPWM_TIMER_0) return MCPWM0B;
else if (timer_num == MCPWM_TIMER_1) return MCPWM1B;
else return MCPWM2B; // MCPWM_TIMER_2
}
}
};

// --- Global Instances ---

// Create an instance of IBusReceiver
// For ESP32, Serial2 is typically used for UART communication
IBusReceiver ibus(Serial2, 16); // Assuming RX is on GPIO 16

// Create an instance of the MyMotor class
MyMotor motor(4, 15); // Use GPIO 4 for forward, GPIO 15 for reverse

// Create instances of the MyServo class
MyServo servo_wheel(MCPWM_UNIT_1, MCPWM_TIMER_0, MCPWM_OPR_A, 25); // Servo on GPIO25
MyServo servo_pan(MCPWM_UNIT_1, MCPWM_TIMER_1, MCPWM_OPR_A, 26); // Servo on GPIO26
MyServo servo_tilt(MCPWM_UNIT_1, MCPWM_TIMER_2, MCPWM_OPR_A, 27); // Servo on GPIO27

// Define GPIO pins for the relays
const int propane_relay = 18; // GPIO 18 controls the propane relay
const int arc_relay = 19; // GPIO 19 controls the arc relay

int tiltMap(int input) {
int output;
// First linear relationship: from input 1060 to 1500, output 181 to 157
if (input <= 1500) {
output = map(input, 1060, 1500, 181, 157);
}
// Second linear relationship: from input 1500 to 2000, output 157 to 100
else {
output = map(input, 1500, 2000, 157, 100);
}
return output;
}

void setup() {
Serial.begin(115200); // Initialize Serial Monitor
ibus.begin(115200); // Initialize IBUS receiver with baud rate 115200

Serial.println("Starting Motor and Servo Control with iBUS Receiver...");

// Initialize relay control pins
pinMode(propane_relay, OUTPUT);
pinMode(arc_relay, OUTPUT);

// Ensure both relays are OFF at startup
digitalWrite(propane_relay, LOW);
digitalWrite(arc_relay, LOW);

// Optional: Give some time for serial ports to initialize
delay(1000);
}

void loop() {
// Read IBUS data
ibus.readIBUSData();

// Process IBUS packet if available
if (ibus.processIBUSPacket()) {
// Retrieve the moveY, moveX, camX, camY, and switchC channel values
int moveY = ibus.getMoveY();
int moveX = ibus.getMoveX();
int camX = ibus.getCamX();
int camY = ibus.getCamY();
int switchC = ibus.getSwitchC();

// Map moveY from 1000-2000 to -100 to 100
int motorSpeed = map(moveY, 1000, 2000, -100, 100);
motor.setSpeed(motorSpeed);

// Map moveX from 1000-2000 to 80-190 degrees
int servo_wheel_angle = map(moveX, 1000, 2000, 190, 80);
servo_wheel.setAngle(servo_wheel_angle);

// Map camX and camY for pan and tilt servos
int servo_pan_angle = map(camX, 1044, 1946, 215, 55);
servo_pan.setAngle(servo_pan_angle);
int servo_tilt_angle = tiltMap(camY);
servo_tilt.setAngle(servo_tilt_angle);

// Control relays based on switchC value
if (switchC == 1000) {
// Both relays OFF
digitalWrite(propane_relay, LOW);
digitalWrite(arc_relay, LOW);
} else if (switchC == 1500) {
// Propane relay ON, Arc relay OFF
digitalWrite(propane_relay, HIGH);
digitalWrite(arc_relay, LOW);
} else if (switchC == 2000) {
// Both relays ON
digitalWrite(propane_relay, HIGH);
digitalWrite(arc_relay, HIGH);
}

// Debugging output
/*
Serial.print("motorSpeed: ");
Serial.print(motorSpeed);
Serial.print(", servo_wheel_angle: ");
Serial.print(servo_wheel_angle);
Serial.print(", servo_pan_angle: ");
Serial.print(servo_pan_angle);
Serial.print(", servo_tilt_angle: ");
Serial.print(servo_tilt_angle);
Serial.print(", switchC: ");
Serial.println(switchC);
*/
}
}

Step 6: Thank You for Making It This Far

I goof around, but seriously, if there's anything you want to hear more about, I am happy to share. This project involved a bunch of different things in a very short period of time. Here's a gratuitous nighttime shot: