רכב רובוטי אלחוטי עם שלט ג'ויסטיק ו-ESP32

בפרויקט זה נבנה רכב רובוטי דו-גלגלי הנשלט מרחוק באמצעות ג'ויסטיק. יחידת השלט קוראת את הג'ויסטיק ושולחת פקודות אלחוטיות אל הרכב, באמצעות שני בקרי ESP32 ושני מודולי nRF24L01+.

מה הרכב יודע לעשות?

הרכב יכול לנסוע קדימה ואחורה, לפנות ימינה ושמאלה, לבצע סיבוב במקום ולשנות את מהירות הנסיעה בהתאם למיקום הג'ויסטיק. המערכת מורכבת משתי יחידות נפרדות:
  • יחידת שלט: ESP32, ג'ויסטיק KY-023 ומודול nRF24L01+.
  • יחידת רכב: ESP32, מודול nRF24L01+, דרייבר מנועים L298N ושני מנועי DC.
מבנה הרכב:

הרכב משתמש בשני מנועים ושני גלגלים מניעים. גלגל תמיכה חופשי נוסף מחזיק את הצד השלישי של השלדה ואינו דורש חיבור חשמלי.

עקרון הפעולה

  1. בקר ה-ESP32 בשלט קורא את הצירים X ו-Y של הג'ויסטיק.
  2. הקוד מחשב את המהירות הרצויה עבור המנוע השמאלי והמנוע הימני.
  3. הפקודה נשלחת אלחוטית באמצעות מודול nRF24L01+.
  4. ה-ESP32 שעל הרכב מקבל את הפקודה.
  5. דרייבר L298N קובע את כיוון הסיבוב ואת מהירות שני המנועים.
  6. אם התקשורת נפסקת, הרכב עוצר אוטומטית מטעמי בטיחות.

רשימת רכיבים

רכיבי יחידת השלט

כמות רכיב תפקיד קישור
1 ESP32 DevKit קריאת הג'ויסטיק ושליחת הפקודות למוצר
1 ג'ויסטיק KY-023 שליטה בכיוון ובמהירות למוצר
1 nRF24L01+ PA+LNA שידור אלחוטי למוצר

רכיבי יחידת הרכב

כמות רכיב תפקיד קישור
1 ESP32 DevKit קבלת הפקודות ושליטה במנועים למוצר
1 nRF24L01+ PA+LNA קליטת הפקודות מהשלט למוצר
1 דרייבר מנועים L298N שליטה בשני מנועי DC למוצר
1 ממיר מתח LM2596 הורדת מתח הסוללות ל-5V למוצר
1 ערכת שלדה לרכב דו-מנועי 2WD שלדה, שני מנועים, שני גלגלים מניעים וגלגל תמיכה למוצר
1 בית סוללות ל-2×18650 מקור המתח של הרכב למוצר
2 סוללת 18650 נטענת אספקת חשמל לרכב למוצר

תרשים חיבור השלט

בשלט מחברים את שני הצירים האנלוגיים של הג'ויסטיק לכניסות ADC של ה-ESP32. מודול nRF24L01+ מחובר באמצעות ממשק SPI.

חיבור הג'ויסטיק

KY-023 ESP32 תפקיד
VCC 3.3V מתח הזנה
GND GND אדמה משותפת
VRx GPIO34 ציר X
VRy GPIO35 ציר Y
SW GPIO32 עצירת חירום

חיבור מודול הרדיו

nRF24L01+ ESP32
CE GPIO4
CSN GPIO5
SCK GPIO18
MISO GPIO19
MOSI GPIO23
IRQ לא מחובר

תרשים חיבור הרכב

תרשים חיבור רכב רובוטי עם ESP32, מודול nRF24L01+, דרייבר L298N ושני מנועי DC

חיבור L298N ל-ESP32

L298N ESP32 תפקיד
ENA GPIO25 PWM מנוע שמאלי
IN1 GPIO26 כיוון מנוע שמאלי
IN2 GPIO27 כיוון מנוע שמאלי
ENB GPIO32 PWM מנוע ימני
IN3 GPIO33 כיוון מנוע ימני
IN4 GPIO14 כיוון מנוע ימני
הערות חשובות:
  • יש לכוון את יציאת ה-LM2596 ל-5.0V לפני חיבור ה-ESP32.
  • יש להסיר את המגשרים ENA ו-ENB כדי לאפשר שליטה במהירות באמצעות PWM.
  • כאשר מזינים את לוגיקת L298N מ-5V חיצוני, יש להסיר גם את מגשר 5V-EN.
  • כל חיבורי ה-GND במערכת חייבים להיות מחוברים יחד.
  • יש לוודא שמודולי nRF24L01+ מקבלים מתח יציב בהתאם לתרשימי החיבור.
  • יש לבחור בבית הסוללות את האפשרות המתאימה לשתי סוללות 18650 בחיבור טורי.

הכנת Arduino IDE

לפני העלאת הקוד יש להכין את Arduino IDE לעבודה עם לוחות ESP32 ולהתקין את ספריית RF24, המשמשת לתקשורת עם מודולי nRF24L01+.

התקנת התמיכה ב-ESP32

פתחו ב-Arduino IDE את התפריט Tools → Board → Boards Manager. בחלון שנפתח חפשו esp32 by Espressif Systems והתקינו גרסה 3.x.

לאחר ההתקנה חברו את ה-ESP32 למחשב ובחרו בתפריט Tools → Board → ESP32 Arduino → ESP32 Dev Module.

התקנת ספריית RF24

פתחו את מנהל הספריות דרך Tools → Manage Libraries, חפשו RF24 והתקינו את הספרייה RF24 by TMRh20.

לאחר סיום ההתקנה ניתן להעלות את קוד השלט ל-ESP32 הראשון ואת קוד הרכב ל-ESP32 השני.

קוד יחידת השלט

#include <Arduino.h>
#include <SPI.h>
#include <RF24.h>

// Joystick
constexpr uint8_t JOYSTICK_X_PIN = 34;
constexpr uint8_t JOYSTICK_Y_PIN = 35;
constexpr uint8_t JOYSTICK_BUTTON_PIN = 32;

// nRF24L01+
constexpr uint8_t NRF_CE_PIN = 4;
constexpr uint8_t NRF_CSN_PIN = 5;
constexpr uint8_t NRF_SCK_PIN = 18;
constexpr uint8_t NRF_MISO_PIN = 19;
constexpr uint8_t NRF_MOSI_PIN = 23;

// Joystick settings
constexpr int JOYSTICK_CENTER = 2048;
constexpr int JOYSTICK_DEAD_ZONE = 300;

// Motor command limit
constexpr int MAX_MOTOR_SPEED = 220;

RF24 radio(NRF_CE_PIN, NRF_CSN_PIN);

const uint8_t RADIO_ADDRESS[6] = "CAR01";

struct __attribute__((packed)) ControlPacket {
  int16_t leftMotor;
  int16_t rightMotor;
  uint8_t emergencyStop;
};

static_assert(
  sizeof(ControlPacket) == 5,
  "Unexpected ControlPacket size"
);

int convertAxis(int rawValue) {
  rawValue = constrain(rawValue, 0, 4095);

  const int difference = rawValue - JOYSTICK_CENTER;

  if (abs(difference) <= JOYSTICK_DEAD_ZONE) {
    return 0;
  }

  if (difference > 0) {
    return static_cast<int>(
      map(
        difference,
        JOYSTICK_DEAD_ZONE,
        4095 - JOYSTICK_CENTER,
        0,
        MAX_MOTOR_SPEED
      )
    );
  }

  return static_cast<int>(
    map(
      difference,
      -JOYSTICK_DEAD_ZONE,
      -JOYSTICK_CENTER,
      0,
      -MAX_MOTOR_SPEED
    )
  );
}

void setup() {
  Serial.begin(115200);

  pinMode(JOYSTICK_BUTTON_PIN, INPUT_PULLUP);
  analogReadResolution(12);

  SPI.begin(
    NRF_SCK_PIN,
    NRF_MISO_PIN,
    NRF_MOSI_PIN,
    NRF_CSN_PIN
  );

  if (!radio.begin(&SPI)) {
    Serial.println("nRF24L01 not detected");

    while (true) {
      delay(1000);
    }
  }

  radio.setAddressWidth(5);
  radio.setChannel(76);

  if (!radio.setDataRate(RF24_250KBPS)) {
    Serial.println("250 kbps mode is not supported");

    while (true) {
      delay(1000);
    }
  }

  radio.setPALevel(RF24_PA_LOW);
  radio.setCRCLength(RF24_CRC_16);
  radio.setAutoAck(true);
  radio.setRetries(5, 15);

  radio.disableDynamicPayloads();
  radio.setPayloadSize(sizeof(ControlPacket));

  radio.openWritingPipe(RADIO_ADDRESS);
  radio.stopListening();

  Serial.println("Transmitter ready");
}

void loop() {
  const int rawX = analogRead(JOYSTICK_X_PIN);
  const int rawY = analogRead(JOYSTICK_Y_PIN);

  const int steering = convertAxis(rawX);
  const int throttle = -convertAxis(rawY);

  const int leftMotor = constrain(
    throttle + steering,
    -MAX_MOTOR_SPEED,
    MAX_MOTOR_SPEED
  );

  const int rightMotor = constrain(
    throttle - steering,
    -MAX_MOTOR_SPEED,
    MAX_MOTOR_SPEED
  );

  ControlPacket packet{};

  packet.emergencyStop =
    digitalRead(JOYSTICK_BUTTON_PIN) == LOW ? 1 : 0;

  if (!packet.emergencyStop) {
    packet.leftMotor =
      static_cast<int16_t>(leftMotor);

    packet.rightMotor =
      static_cast<int16_t>(rightMotor);
  }

  const bool sent = radio.write(
    &packet,
    sizeof(packet)
  );

  Serial.print("Left: ");
  Serial.print(packet.leftMotor);

  Serial.print(" | Right: ");
  Serial.print(packet.rightMotor);

  Serial.print(" | Status: ");
  Serial.println(sent ? "OK" : "FAILED");

  delay(30);
}

קוד יחידת הרכב

#include <Arduino.h>
#include <SPI.h>
#include <RF24.h>

// nRF24L01+
constexpr uint8_t NRF_CE_PIN = 4;
constexpr uint8_t NRF_CSN_PIN = 5;
constexpr uint8_t NRF_SCK_PIN = 18;
constexpr uint8_t NRF_MISO_PIN = 19;
constexpr uint8_t NRF_MOSI_PIN = 23;

// Left motor
constexpr uint8_t LEFT_ENA_PIN = 25;
constexpr uint8_t LEFT_IN1_PIN = 26;
constexpr uint8_t LEFT_IN2_PIN = 27;

// Right motor
constexpr uint8_t RIGHT_ENB_PIN = 32;
constexpr uint8_t RIGHT_IN3_PIN = 33;
constexpr uint8_t RIGHT_IN4_PIN = 14;

// PWM
constexpr uint32_t PWM_FREQUENCY = 1000;
constexpr uint8_t PWM_RESOLUTION = 8;

// Motor command limit
constexpr int MAX_MOTOR_SPEED = 220;

// Stop the car if communication is lost
constexpr unsigned long CONNECTION_TIMEOUT_MS = 500;

RF24 radio(NRF_CE_PIN, NRF_CSN_PIN);

const uint8_t RADIO_ADDRESS[6] = "CAR01";

struct __attribute__((packed)) ControlPacket {
  int16_t leftMotor;
  int16_t rightMotor;
  uint8_t emergencyStop;
};

static_assert(
  sizeof(ControlPacket) == 5,
  "Unexpected ControlPacket size"
);

unsigned long lastPacketTime = 0;

void controlMotor(
  int speed,
  uint8_t enablePin,
  uint8_t input1Pin,
  uint8_t input2Pin
) {
  speed = constrain(
    speed,
    -MAX_MOTOR_SPEED,
    MAX_MOTOR_SPEED
  );

  if (speed > 0) {
    digitalWrite(input1Pin, HIGH);
    digitalWrite(input2Pin, LOW);

    ledcWrite(
      enablePin,
      static_cast<uint32_t>(speed)
    );

    return;
  }

  if (speed < 0) {
    digitalWrite(input1Pin, LOW);
    digitalWrite(input2Pin, HIGH);

    ledcWrite(
      enablePin,
      static_cast<uint32_t>(-speed)
    );

    return;
  }

  digitalWrite(input1Pin, LOW);
  digitalWrite(input2Pin, LOW);
  ledcWrite(enablePin, 0);
}

void stopMotors() {
  controlMotor(
    0,
    LEFT_ENA_PIN,
    LEFT_IN1_PIN,
    LEFT_IN2_PIN
  );

  controlMotor(
    0,
    RIGHT_ENB_PIN,
    RIGHT_IN3_PIN,
    RIGHT_IN4_PIN
  );
}

void setup() {
  Serial.begin(115200);

  pinMode(LEFT_IN1_PIN, OUTPUT);
  pinMode(LEFT_IN2_PIN, OUTPUT);

  pinMode(RIGHT_IN3_PIN, OUTPUT);
  pinMode(RIGHT_IN4_PIN, OUTPUT);

  const bool leftPwmReady = ledcAttach(
    LEFT_ENA_PIN,
    PWM_FREQUENCY,
    PWM_RESOLUTION
  );

  const bool rightPwmReady = ledcAttach(
    RIGHT_ENB_PIN,
    PWM_FREQUENCY,
    PWM_RESOLUTION
  );

  if (!leftPwmReady || !rightPwmReady) {
    Serial.println("PWM setup failed");

    while (true) {
      stopMotors();
      delay(1000);
    }
  }

  stopMotors();

  SPI.begin(
    NRF_SCK_PIN,
    NRF_MISO_PIN,
    NRF_MOSI_PIN,
    NRF_CSN_PIN
  );

  if (!radio.begin(&SPI)) {
    Serial.println("nRF24L01 not detected");

    while (true) {
      stopMotors();
      delay(1000);
    }
  }

  radio.setAddressWidth(5);
  radio.setChannel(76);

  if (!radio.setDataRate(RF24_250KBPS)) {
    Serial.println("250 kbps mode is not supported");

    while (true) {
      stopMotors();
      delay(1000);
    }
  }

  radio.setPALevel(RF24_PA_LOW);
  radio.setCRCLength(RF24_CRC_16);
  radio.setAutoAck(true);

  radio.disableDynamicPayloads();
  radio.setPayloadSize(sizeof(ControlPacket));

  radio.openReadingPipe(1, RADIO_ADDRESS);
  radio.flush_rx();
  radio.startListening();

  lastPacketTime = millis();

  Serial.println("Receiver ready");
}

void loop() {
  if (radio.available()) {
    ControlPacket packet{};

    // If several commands are waiting,
    // keep only the newest one.
    do {
      radio.read(
        &packet,
        sizeof(packet)
      );
    } while (radio.available());

    lastPacketTime = millis();

    const int leftMotor = constrain(
      static_cast<int>(packet.leftMotor),
      -MAX_MOTOR_SPEED,
      MAX_MOTOR_SPEED
    );

    const int rightMotor = constrain(
      static_cast<int>(packet.rightMotor),
      -MAX_MOTOR_SPEED,
      MAX_MOTOR_SPEED
    );

    if (packet.emergencyStop != 0) {
      stopMotors();
    } else {
      controlMotor(
        leftMotor,
        LEFT_ENA_PIN,
        LEFT_IN1_PIN,
        LEFT_IN2_PIN
      );

      controlMotor(
        rightMotor,
        RIGHT_ENB_PIN,
        RIGHT_IN3_PIN,
        RIGHT_IN4_PIN
      );
    }

    Serial.print("Left: ");
    Serial.print(leftMotor);

    Serial.print(" | Right: ");
    Serial.println(rightMotor);
  }

  if (
    millis() - lastPacketTime >
    CONNECTION_TIMEOUT_MS
  ) {
    stopMotors();
  }
}

הפעלה ראשונית

  1. כוונו את יציאת ה-LM2596 למתח של 5.0V באמצעות מולטימטר.
  2. בדקו את כל חיבורי המתח וה-GND.
  3. העלו את קוד השלט ל-ESP32 הראשון.
  4. העלו את קוד הרכב ל-ESP32 השני.
  5. הפעילו תחילה את הרכב ולאחר מכן את השלט.
  6. הזיזו את הג'ויסטיק באיטיות ובדקו את כיוון סיבוב המנועים.

כיול ושינוי כיוון

אם הרכב נוסע לאחור כאשר הג'ויסטיק מוזז קדימה, ניתן להחליף בין שני חוטי המנועים או לשנות את הסימן של משתנה throttle בקוד השלט. אם מנוע אחד מסתובב בכיוון הפוך מהמנוע השני, יש להחליף בין שני החוטים של אותו מנוע בלבד. אם הרכב נע גם כאשר הג'ויסטיק נמצא במרכז, ניתן להגדיל את הערך:
JOYSTICK_DEAD_ZONE

אפשרויות לשדרוג הפרויקט

  • הוספת חיישן מרחק למניעת התנגשות.
  • הוספת תאורת LED הנשלטת מהג'ויסטיק.
  • הוספת חיישני מעקב אחר קו.
  • הוספת מד מתח להצגת מצב הסוללות.
  • הוספת מצלמה או ESP32-CAM.
  • הוספת כפתורים נוספים לשליטה באביזרים.
דילוג לתוכן