因為某人曾經騎自行車摔車受傷,生活不方便一段時間,所以為了讓每個人都不再痛苦,我們決定做一台永遠不會摔車的自行車造福大眾。
但是因為成本問題只能做一台縮小版自平衡遙控自行車,大概1:7的大小。
在不受外力時自平衡不傾倒
受小規模外力時自平衡不傾倒
得用搖桿控制自行車前後左右不傾倒
目標示範影片
411214306 林致宇
411214307 胡朝凱
411214328 沈玿正
Arduino Nano × 2
搖桿
螢幕
伺服馬達
無刷馬達 × 1
減速無刷馬達 × 1
nRF24L01 × 2
PCB洞洞板(5×7 cm)× 3
鋰電池
鋰電池充電模組
皮帶
皮帶同步輪 × 2
升降壓模組
電線
螺絲
3D列印件
總採購成本約 NT$900
部分材料
測量零件尺寸並透過 Shaper3D 建立合理的結構,並匯出為 .step 檔案。
自平衡車 3D 建模
遙控器 3D 建模
將 .step 檔案匯入切片軟體,適當設定填充、支撐等參數後產生 gcode 傳送至 3D 列印機進行列印。
使用的材料為 PLA、PETG。
3D 列印成品(部分)
問題:驅動皮帶尺寸不易計算
解方:將馬達支架固定位置設計為可調距離
可調的馬達支架
問題:自平衡車適用的電池太貴
解方:改用電源供應器直接提供電力
用電源供應器直接供電
問題:車太長
解方:將驅動後輪的無刷馬達改到後輪上方
太長的後車體
改善的後車體
平衡控制
利用 MPU6050 感測器(內含加速度計與陀螺儀)判斷車體是否傾斜。
卡爾曼濾波 + 互補濾波整合加速度與角速度,計算出車子的傾斜角度。
當傾斜角偏離太大時,就會驅動一個無刷馬達(MOTOR_2),產生推力使車子回正。
使用 PID 控制公式(比例 P、微分 D、積分 I)控制馬達的轉速與方向,使車子穩定維持直立。
無線通訊
使用 nRF24L01 無線模組接收來自搖桿(遙控器)的訊號。
遙控器會傳送兩個數值:
前後方向(速度控制)
左右方向(轉向控制)
若超過一段時間未收到訊號,系統會自動停車以避免暴衝。
伺服馬達轉向
搖桿左右方向對應到一個舵機(伺服馬達 SERVO_1)。
根據遙控器指令,改變前輪的方向來讓車子轉彎。
轉向角度有設最低與最高限制,確保不會過度扭轉。
推進馬達控制
根據搖桿上下的指令,控制一顆減速無刷馬達(MOTOR_1)。
使用 PWM 控制轉速,並依照搖桿方向切換前進或後退。
若沒有訊號或搖桿在中間值則停車。
編碼器讀取
無刷馬達軸上連接有編碼器,用來偵測實際的動量輪轉速,用來修正 PID 控制器的輸出。
程式碼(點擊展開)
// vehicle.ino
// I2C pins for MPU6050 communication
#define I2C_SDA A4
#define I2C_SCL A5
// MPU6050 I2C address
#define MPU6050_ADDR 0x68
// SPI pins for nRF24L01
#define SPI_MOSI 11
#define SPI_MISO 12
#define SPI_SCK 13
// nRF24L01 control pins
#define NRF24L01_CE 9
#define NRF24L01_CSN 10
// Servo motor (steering) configuration
#define SERVO_1_SIG 8
#define SERVO_1_CENTER 90
#define SERVO_1_MIN 40
#define SERVO_1_MAX 140
// Brushless motor (drive) pins
#define MOTOR_1_PWM 6
#define MOTOR_1_DIR 7
// Brushless motor (balance wheel) pins
#define MOTOR_2_PWM 5
#define MOTOR_2_DIR 4
#define MOTOR_2_EN A1
#define MOTOR_2_ENC_A 2
#define MOTOR_2_ENC_B 3
// Timeout for lost radio connection
#define LOST_CONNECTION_TIMEOUT_MS 200
// Control limits
#define STEERING_MAX 350
#define SPEED_MAX 80
#define STEERING_CENTER 1500
#define SPEED_LIMIT 4
#include <RF24.h>
#include <SPI.h>
#include <Servo.h>
#include <Wire.h>
#include <nRF24L01.h>
#include "printf.h"
// Gyro and accelerometer variables
float gyroAmount = 0.996; // Weight for gyro in angle calculation
bool isVertical = false; // Indicates if vehicle is balanced
float k1 = 150; // PID
float k2 = 8; // PID
float k3 = 80; // PID
float k4 = 0.6; // PID
float loopTime = 10; // Loop time in milliseconds
struct OffsetsObj {
int16_t acY;
int16_t acZ;
};
OffsetsObj offsets; // Calibration offsets
float alpha = 0.4; // Filter coefficient for gyro
int16_t acY, acZ, gyX, gyroXfilt; // Raw and filtered sensor data
int16_t acYc, acZc; // Corrected accelerometer data
int16_t gyXOffset = 0; // Gyro offset
int32_t gyXOffsetSum = 0; // Sum for gyro offset calculation
float robotAngle; // Calculated robot angle
float accAngle; // Accelerometer-based angle
volatile byte pos; // Encoder position
volatile int encCount = 0; // Encoder count
int16_t motorSpeed; // Motor speed
int32_t motorPos; // Motor position
int speedValue = 0; // Current speed value
int steeringValue = STEERING_CENTER; // Current steering value
long currentTime, previousTime = 0; // Timing variables
RF24 radio(NRF24L01_CE, NRF24L01_CSN);
const byte address[6] = "00315"; // Radio address
// Data structure for remote control input
struct RemoteData {
uint16_t joystickX; // Joystick X-axis (speed)
uint16_t joystickY; // Joystick Y-axis (steering)
bool joystickSw; // Joystick button
};
RemoteData remoteData;
Servo servo1;
void setupRadio() {
Serial.println("Radio initializing...");
radio.begin();
radio.setAutoAck(true); // Enable auto-acknowledgment
radio.setChannel(82); // Set RF channel
radio.setPALevel(RF24_PA_MAX); // Set power level to maximum
radio.setDataRate(RF24_2MBPS); // Set data rate
radio.openReadingPipe(1, address); // Open reading pipe
radio.startListening(); // Enter receive mode
radio.printDetails(); // Print radio configuration
Serial.println("Radio initialized");
}
void setupServo1() {
Serial.println("Servo 1 initializing...");
servo1.attach(SERVO_1_SIG); // Attach servo to signal pin
servo1.write(SERVO_1_CENTER); // Center the servo
Serial.println("Servo 1 initialized");
}
void setupMotor1() {
Serial.println("Motor 1 initializing...");
pinMode(MOTOR_1_PWM, OUTPUT);
digitalWrite(MOTOR_1_PWM, HIGH); // Stop motor
pinMode(MOTOR_1_DIR, OUTPUT);
Serial.println("Motor 1 initialized");
}
void setupMotor2() {
Serial.println("Motor 2 initializing...");
pinMode(MOTOR_2_PWM, OUTPUT);
digitalWrite(MOTOR_2_PWM, LOW); // Stop motor
pinMode(MOTOR_2_DIR, OUTPUT);
pinMode(MOTOR_2_EN, OUTPUT);
pinMode(MOTOR_2_ENC_A, INPUT);
pinMode(MOTOR_2_ENC_B, INPUT);
Serial.println("Motor 2 initialized");
}
void setup() {
Serial.begin(115200);
Serial.println("Vehicle initializing...");
printf_begin(); // Initialize printf for radio debugging
setupRadio();
setupServo1();
setupMotor1();
setupMotor2();
motor2Control(0); // Stop balance motor
Serial.println("Interrupts initializing...");
attachInterrupt(0, encRead, CHANGE); // Encoder interrupt
attachInterrupt(1, encRead, CHANGE);
Serial.println("Interrupts initialized");
// Initialize calibration offsets
offsets.acY = -550;
offsets.acZ = -2450;
delay(3000); // Wait for system stabilization
angleSetup(); // Initialize MPU6050
Serial.println("Vehicle initialized");
}
void loop() {
static unsigned long lastReceivedTime = 0;
static bool isConnected = false;
// Check for incoming radio data
if (radio.available()) {
radio.read(&remoteData, sizeof(remoteData));
lastReceivedTime = millis(); // Update last received time
isConnected = true; // Mark as connected
} else if (millis() - lastReceivedTime > LOST_CONNECTION_TIMEOUT_MS) {
// Clear remote data on connection loss
memset(&remoteData, 0, sizeof(remoteData));
isConnected = false;
}
// Control drive motor with joystick X-axis
if (!isConnected) {
digitalWrite(MOTOR_1_DIR, LOW);
digitalWrite(MOTOR_1_PWM, HIGH); // Stop motor
} else if (remoteData.joystickX == 256) {
digitalWrite(MOTOR_1_DIR, LOW);
digitalWrite(MOTOR_1_PWM, HIGH); // Stop motor
} else if (remoteData.joystickX < 256) {
digitalWrite(MOTOR_1_DIR, LOW); // Forward
int pwmValue = map(remoteData.joystickX, 0, 256, 0, 255);
pwmValue = constrain(pwmValue, 0, 255);
analogWrite(MOTOR_1_PWM, pwmValue);
} else {
digitalWrite(MOTOR_1_DIR, HIGH); // Backward
int pwmValue = map(remoteData.joystickX, 256, 512, 255, 0);
pwmValue = constrain(pwmValue, 0, 255);
analogWrite(MOTOR_1_PWM, pwmValue);
}
// Control servo with joystick Y-axis
if (!isConnected || remoteData.joystickY == 256) {
servo1.write(SERVO_1_CENTER); // Center servo
} else if (remoteData.joystickY < 256) {
servo1.write(map(remoteData.joystickY, 0, 256, SERVO_1_MIN, SERVO_1_CENTER));
} else {
servo1.write(map(remoteData.joystickY, 256, 512, SERVO_1_CENTER, SERVO_1_MAX));
}
currentTime = millis();
if (currentTime - previousTime >= loopTime) {
angleCalc(); // Calculate robot angle
motorSpeed = -encCount; // Update motor speed
encCount = 0; // Reset encoder count
if (isVertical) {
digitalWrite(MOTOR_2_EN, HIGH); // Enable balance motor
float gyroX = gyX / 131.0; // Convert gyro to deg/s
gyroXfilt = alpha * gyroX + (1 - alpha) * gyroXfilt; // Filter gyro
motorPos += motorSpeed; // Update motor position
motorPos = constrain(motorPos, -110, 110);
// Calculate PID control for balance motor
int pwm = constrain(
k1 * robotAngle + k2 * gyroXfilt + k3 * motorSpeed + k4 * motorPos,
-255, 255);
motor2Control(-pwm); // Apply control
} else {
digitalWrite(MOTOR_2_EN, LOW); // Disable motor
speedValue = 0;
motor2Control(0);
motorPos = 0;
}
previousTime = currentTime;
}
}
// functions.ino
// MPU6050 register addresses
#define ACCEL_CONFIG 0x1C
#define GYRO_CONFIG 0x1B
#define ACCEL_YOUT_H 0x3D
#define ACCEL_ZOUT_H 0x3F
#define GYRO_XOUT_H 0x43
#define PWR_MGMT_1 0x6B
#define ACC_SENS 0 // Accelerometer sensitivity
#define GYRO_SENS 1 // Gyro sensitivity
void writeTo(byte device, byte address, byte value) {
// Write a single byte to the specified I2C device register
Wire.beginTransmission(device);
Wire.write(address);
Wire.write(value);
Wire.endTransmission(true);
}
void angleCalc() {
// Read accelerometer Y-axis
Wire.beginTransmission(MPU6050_ADDR);
Wire.write(ACCEL_YOUT_H);
Wire.endTransmission(false);
Wire.requestFrom(MPU6050_ADDR, 2, true);
acY = Wire.read() << 8 | Wire.read();
// Read accelerometer Z-axis
Wire.beginTransmission(MPU6050_ADDR);
Wire.write(ACCEL_ZOUT_H);
Wire.endTransmission(false);
Wire.requestFrom(MPU6050_ADDR, 2, true);
acZ = Wire.read() << 8 | Wire.read();
// Read gyro X-axis
Wire.beginTransmission(MPU6050_ADDR);
Wire.write(GYRO_XOUT_H);
Wire.endTransmission(false);
Wire.requestFrom(MPU6050_ADDR, 2, true);
gyX = Wire.read() << 8 | Wire.read();
// Apply calibration offsets
acYc = acY - offsets.acY;
acZc = acZ - offsets.acZ;
gyX -= gyXOffset;
// Calculate robot angle using complementary filter
robotAngle += gyX * loopTime / 1000 / 65.536; // Integrate gyro
accAngle = -atan2(acYc, -acZc) * 57.2958; // Accelerometer angle
robotAngle = robotAngle * gyroAmount + accAngle * (1.0 - gyroAmount);
// Update balance state
isVertical = (abs(robotAngle) < 0.1);
}
void angleSetup() {
// Initialize MPU6050
Wire.begin();
delay(100);
writeTo(MPU6050_ADDR, PWR_MGMT_1, 0); // Wake up MPU6050
writeTo(MPU6050_ADDR, ACCEL_CONFIG, ACC_SENS << 3); // Set accelerometer sensitivity
writeTo(MPU6050_ADDR, GYRO_CONFIG, GYRO_SENS << 3); // Set gyro sensitivity
delay(100);
// Calibrate gyro offset
for (int i = 0; i < 1024; i++) {
angleCalc();
gyXOffsetSum += gyX;
delay(3);
}
gyXOffset = gyXOffsetSum >> 10; // Average offset
Serial.print("GyX offset: ");
Serial.println(gyXOffset);
}
void motor2Control(int sp) {
// Control balance motor speed and direction
if (sp > 0)
digitalWrite(MOTOR_2_DIR, HIGH); // Forward
else
digitalWrite(MOTOR_2_DIR, LOW); // Reverse
analogWrite(MOTOR_2_PWM, 255 - abs(sp)); // Apply PWM value
}
void encRead() {
// Read encoder state for balance motor
byte cur = (!digitalRead(MOTOR_2_ENC_A) << 1) + !digitalRead(MOTOR_2_ENC_B);
byte old = pos & B00000011;
byte dir = (pos & B00110000) >> 4;
if (cur == 3)
cur = 2;
else if (cur == 2)
cur = 3;
if (cur != old) {
if (dir == 0) {
if (cur == 1 || cur == 3)
dir = cur;
} else {
if (cur == 0) {
if (dir == 1 && old == 3)
encCount--;
else if (dir == 3 && old == 1)
encCount++;
dir = 0;
}
}
pos = (dir << 4) + (old << 2) + cur;
}
}
搖桿訊號讀取
使用一個雙軸搖桿元件讀取:
上下方向(Y 軸)→ 控制車子前進與後退。
左右方向(X 軸)→ 控制車子轉彎。
按鈕(SW)→ 可當作功能觸發器。
中心區域會略作濾波(避免抖動),其餘會映射為 0–512 的控制數值(以 256 為中點)。
無線通訊(nRF24L01)
使用 nRF24L01 無線模組持續發送搖桿訊號至車體端。
傳送的資料包含搖桿 X/Y 值與按鈕狀態,封裝為自訂的 RemoteData 結構。
使用自動回應機制(AutoAck),可判斷目前是否已成功連線到車子。
OLED 螢幕即時顯示
透過 0.96 吋 OLED 螢幕(SSD1306 模組)即時顯示控制狀態:
顯示是否已連線成功。
顯示目前搖桿的 X、Y 數值與按鈕狀態。
可作為簡易的回饋與偵錯介面。
程式碼(點擊展開)
// remote.ino
// I2C
#define I2C_SDA A4
#define I2C_SCL A5
// SSD1306 OLED
#define SSD1306_ADDR 0x3C
// SPI
#define SPI_MOSI 11
#define SPI_MISO 12
#define SPI_SCK 13
// nRF24L01
#define NRF24L01_CE 9
#define NRF24L01_CSN 10
// JoyStick
#define JOYSTICK_X A0
#define JOYSTICK_Y A1
#define JOYSTICK_SW 2
#define JOYSTICK_X_CENTER 525
#define JOYSTICK_Y_CENTER 505
#define JOYSTICK_FILTER 10
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <RF24.h>
#include <nRF24L01.h>
Adafruit_SSD1306 display(128, 64, &Wire, -1);
RF24 radio(NRF24L01_CE, NRF24L01_CSN);
const byte address[6] = "00315";
// 定義數據結構
struct RemoteData {
uint16_t joystickX; // JoyStick X 軸
uint16_t joystickY; // JoyStick Y 軸
bool joystickSW; // JoyStick 按鈕
};
RemoteData remoteData;
void setupRadio() {
Serial.println("Radio initializing...");
radio.begin();
radio.setAutoAck(true); // 啟用自動確認
radio.setChannel(82);
radio.setPALevel(RF24_PA_MAX);
radio.setDataRate(RF24_2MBPS);
radio.openWritingPipe(address);
radio.stopListening(); // 設定為發射模式
radio.printDetails(); // 輸出模組詳細資訊
Serial.println("Radio iinitialized");
}
void setupOLED() {
Serial.println("OLED initializing...");
if (!display.begin(SSD1306_SWITCHCAPVCC, SSD1306_ADDR)) {
Serial.println("SSD1306 allocation failed");
for (;;); // 不繼續執行
}
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(1);
display.setCursor(0, 0);
display.print("HELLO");
display.display();
Serial.println("OLED initialized");
}
void setupJoystick() {
Serial.println("Joystick initializing...");
pinMode(JOYSTICK_X, INPUT);
pinMode(JOYSTICK_Y, INPUT);
pinMode(JOYSTICK_SW, INPUT_PULLUP);
Serial.println("Joystick initialized");
}
void setup() {
Serial.begin(115200);
Serial.println("Remote initializing...");
setupRadio();
setupJoystick();
setupOLED();
Serial.println("Remote initialized");
delay(1000);
}
int joystickX = 0;
int joystickY = 0;
bool joystickSW = false;
void loop() {
// 讀取 JoyStick 的值
joystickX = analogRead(JOYSTICK_X);
joystickY = analogRead(JOYSTICK_Y);
joystickSW = digitalRead(JOYSTICK_SW) == LOW; // 按下時為 LOW
// 計算 JoyStick X 值的映射
if (joystickX < JOYSTICK_X_CENTER - JOYSTICK_FILTER) {
// 如果小於中心值減去濾波值,則映射到 0-256
remoteData.joystickX = map(joystickX, 0, JOYSTICK_X_CENTER - JOYSTICK_FILTER, 0, 256);
} else if (joystickX > JOYSTICK_X_CENTER + JOYSTICK_FILTER) {
// 如果大於中心值加上濾波值,則映射到 256-512
remoteData.joystickX = map(joystickX, JOYSTICK_X_CENTER + JOYSTICK_FILTER, 1023, 256, 512);
} else {
remoteData.joystickX = 256; // 中心值
}
// 計算 JoyStick Y 值的映射
if (joystickY < JOYSTICK_Y_CENTER - JOYSTICK_FILTER) {
// 如果小於中心值減去濾波值,則映射到 0-256
remoteData.joystickY = map(joystickY, 0, JOYSTICK_Y_CENTER - JOYSTICK_FILTER, 0, 256);
} else if (joystickY > JOYSTICK_Y_CENTER + JOYSTICK_FILTER) {
// 如果大於中心值加上濾波值,則映射到 256-512
remoteData.joystickY = map(joystickY, JOYSTICK_Y_CENTER + JOYSTICK_FILTER, 1023, 256, 512);
} else {
remoteData.joystickY = 256; // 中心值
}
// 設定 JoyStick 按鈕的值
remoteData.joystickSW = joystickSW;
// 發送數據
bool ackReceived = radio.write(&remoteData, sizeof(remoteData));
// 在 OLED 上顯示 JoyStick 的值
display.clearDisplay();
display.setCursor(0, 0);
display.println(ackReceived ? "CONNECTED" : "DISCONNECT");
display.setCursor(0, 16);
display.print("X: ");
display.println(remoteData.joystickX);
display.print("Y: ");
display.println(remoteData.joystickY);
display.print("SW: ");
display.println(remoteData.joystickSW ? "1" : "0");
display.display();
}
✅ 在不受外力時自平衡不傾倒
✅ 受小規模外力時自平衡不傾倒
❌ 得用搖桿控制自行車前後左右不傾倒
無須任何輔助即可自平衡
自平衡車及遙控器
功能測試影片
這次我們做自平衡車的專題,我主要負責的是 3D 繪圖列印跟程式設計。建模的時候要考慮零件尺寸、馬達位置跟線路孔位,一開始常常印出來不合,要一直改尺寸重印。後來比較熟練後,就能抓到哪裡需要留空、哪裡需要加強結構。
程式的部分主要是撰寫控制車子平衡的邏輯,必須即時處理姿態感測器(MPU6050)提供的角度與角速度資料,並搭配 PID 控制演算法進行調整。我們用了 PID 控制器來維持車子的直立平衡,根據傾角、角速度、馬達轉速與位置偏移等參數,綜合計算出適當的輸出力道,驅動平衡馬達進行補償。這些參數的權重設定非常關鍵,光是調整就測試了上百組 k 值,只要一點點不對,車子就會開始搖晃、劇烈震動,甚至瞬間倒下。
過程中我也學到了如何讀取 MPU6050 的加速度與陀螺儀數據,並透過互補濾波來計算出較穩定的姿態角度。同時也理解了 PID 各項參數(比例、微分、積分)在動態控制中的角色,例如比例項決定修正強度、微分項可抑制快速變化、積分項則用來修正長期偏差。
雖然這段過程十分反覆,常常一調整就要連續測試、debug 到深夜,但當看到車子終於能夠穩穩站好,並實現前後平衡移動時,那份成就感真的難以取代。
在專題前期過程還算順利沒有遇到什麼奇怪的問題,但隨著一個一個零件組裝起來,出現了很多預料外的困難,像是車體太長操控時會難以控制或是皮帶和皮帶同步輪的位置距離不好計算,雖然無法做到完全解決,但我們還是盡量讓影響降到最低,也多虧這些困難讓我了解到硬體組裝及整合的不易。
這次我們做自平衡自行車的專題,我主要負責的是電路的焊接部分。其實原本以為就是把線焊一焊、接起來而已,但實際做才發現,真的有很多細節要注意。像是焊接的時候,錫量不能太多也不能太少,不然不是短路就是焊不上去,而且有些排針很密,一不小心就會焊在一起,要一直用吸錫器重來。整線的部分也滿有挑戰的,因為如果線路拉得亂亂的,不只不好看,也會影響後續維修或調整。因此花了許多時間在思考要怎麼排線才不會交錯,也盡量讓電路板整齊、好看,努力完善我所負責的內容。