1. 修改目標
原本手機網頁有三個滑桿:
| 原本控制 | 改成 | 用途 |
|---|---|---|
| 方向舵滑桿 | XY 搖桿的 X 軸 | 左右控制方向舵 Rudder |
| 升降舵滑桿 | XY 搖桿的 Y 軸 | 上下控制升降舵 Elevator |
| ESC 油門滑桿 | 保留油門滑桿 | 1000–2000 µs 控制無刷馬達油門 |
安全提醒:測試 ESC 與無刷馬達時,請先拆掉螺旋槳。課堂第一次測試建議只接 Servo,不接馬達或不裝槳。
升降舵 ↑
升降舵 ↓
← 方向舵
方向舵 →
2. 需要修改哪裡?
如果你的 ESP32-CAM 程式已經可以用手機網頁控制三個滑桿,通常只需要改 htmlPage() 這一段。
老師版重點:保留原本的
/set?r=...&e=...&t=... 後端控制格式,只把手機看到的網頁介面改成類比搖桿。
保留
Servo 腳位設定、Wi-Fi AP 設定、
Servo 腳位設定、Wi-Fi AP 設定、
handleSet()、loop() 失控保護。取代
將原本的
將原本的
String htmlPage() 整段換成下面的搖桿版本。可調整
修改
修改
servoRange、reverseRudder、reverseElevator。3. 修改步驟
1
打開原本的 Arduino 程式,找到
String htmlPage()。2
把原本三個滑桿的 HTML 網頁內容整段刪除。
3
貼上第 4 節提供的新版
htmlPage()。4
確認
handleSet() 還是接收 r、e、t 三個參數。5
上傳到 ESP32-CAM,手機連上 ESP32-CAM Wi-Fi 後開啟
http://192.168.4.1。4. 新版手機控制網頁:直接取代 htmlPage()
請將你的 Arduino 程式裡原本的 String htmlPage() 整段換成以下版本。
新版 htmlPage():XY 搖桿 + ESC 油門
String htmlPage() {
String page = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>ESP32-CAM RC Joystick Control</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
background: #f5f5f5;
margin: 0;
padding: 16px;
touch-action: none;
user-select: none;
}
h1 {
font-size: 26px;
margin-bottom: 8px;
}
.warning {
color: red;
font-weight: bold;
font-size: 18px;
}
.panel {
background: white;
padding: 18px;
margin: 14px auto;
max-width: 460px;
border-radius: 14px;
box-shadow: 0 0 8px rgba(0,0,0,0.18);
}
#joystickArea {
width: 280px;
height: 280px;
background: #e0e0e0;
border: 4px solid #555;
border-radius: 50%;
margin: 20px auto;
position: relative;
touch-action: none;
}
#joystickKnob {
width: 86px;
height: 86px;
background: #333;
border-radius: 50%;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 10px rgba(0,0,0,0.35);
}
.crossLineH {
position: absolute;
width: 100%;
height: 2px;
background: rgba(0,0,0,0.25);
top: 50%;
left: 0;
}
.crossLineV {
position: absolute;
width: 2px;
height: 100%;
background: rgba(0,0,0,0.25);
left: 50%;
top: 0;
}
.value {
font-size: 22px;
font-weight: bold;
}
input[type=range] {
width: 90%;
height: 36px;
}
button {
font-size: 20px;
padding: 10px 24px;
margin-top: 12px;
border-radius: 8px;
}
</style>
</head>
<body>
<h1>ESP32-CAM RC Joystick Control</h1>
<p class="warning">測試時請先拆掉螺旋槳</p>
<div class="panel">
<h2>方向舵 / 升降舵</h2>
<div id="joystickArea">
<div class="crossLineH"></div>
<div class="crossLineV"></div>
<div id="joystickKnob"></div>
</div>
<p>方向舵 Rudder:<span id="rudderVal" class="value">90</span>°</p>
<p>升降舵 Elevator:<span id="elevatorVal" class="value">90</span>°</p>
</div>
<div class="panel">
<h2>ESC 油門</h2>
<input id="throttle" type="range" min="1000" max="2000" value="1000">
<p>油門 PWM:<span id="throttleVal" class="value">1000</span> µs</p>
<button onclick="stopMotor()">立即關油門</button>
</div>
<script>
const joystickArea = document.getElementById("joystickArea");
const joystickKnob = document.getElementById("joystickKnob");
const rudderVal = document.getElementById("rudderVal");
const elevatorVal = document.getElementById("elevatorVal");
const throttle = document.getElementById("throttle");
const throttleVal = document.getElementById("throttleVal");
// ===== Servo 控制參數 =====
// Servo 中立點。一般 RC 舵面中立為 90 度。
let rudderCenter = 90;
let elevatorCenter = 90;
// Servo 最大偏轉量。
// 建議上課初期使用 45,代表搖桿打到底時是 45 到 135 度。
let servoRange = 45;
// 如果舵面方向相反,把 false 改成 true。
let reverseRudder = false;
let reverseElevator = false;
// ===== 目前控制值 =====
let rudder = 90;
let elevator = 90;
let throttleUs = 1000;
let joystickActive = false;
// ===== 傳送控制資料給 ESP32-CAM =====
function sendControl() {
fetch("/set?r=" + rudder +
"&e=" + elevator +
"&t=" + throttleUs)
.catch(err => console.log(err));
}
// ===== 更新畫面數值 =====
function updateDisplay() {
rudderVal.innerHTML = rudder;
elevatorVal.innerHTML = elevator;
throttleVal.innerHTML = throttleUs;
}
// ===== 限制數值範圍 =====
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
// ===== 依照手指位置更新搖桿 =====
function updateJoystick(clientX, clientY) {
const rect = joystickArea.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
let dx = clientX - centerX;
let dy = clientY - centerY;
const knobRadius = joystickKnob.offsetWidth / 2;
const maxRadius = rect.width / 2 - knobRadius;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > maxRadius) {
dx = dx / distance * maxRadius;
dy = dy / distance * maxRadius;
}
joystickKnob.style.left = (rect.width / 2 + dx) + "px";
joystickKnob.style.top = (rect.height / 2 + dy) + "px";
// xNorm: 左右方向,範圍 -1 到 +1
// yNorm: 上下方向,範圍 -1 到 +1
let xNorm = dx / maxRadius;
let yNorm = -dy / maxRadius;
if (reverseRudder) {
xNorm = -xNorm;
}
if (reverseElevator) {
yNorm = -yNorm;
}
rudder = Math.round(rudderCenter + xNorm * servoRange);
elevator = Math.round(elevatorCenter + yNorm * servoRange);
rudder = clamp(rudder, 0, 180);
elevator = clamp(elevator, 0, 180);
updateDisplay();
sendControl();
}
// ===== 搖桿放手後自動回中 =====
function centerJoystick() {
joystickKnob.style.left = "50%";
joystickKnob.style.top = "50%";
rudder = rudderCenter;
elevator = elevatorCenter;
updateDisplay();
sendControl();
}
// ===== 手機觸控 / 滑鼠事件 =====
joystickArea.addEventListener("pointerdown", function(e) {
joystickActive = true;
joystickArea.setPointerCapture(e.pointerId);
updateJoystick(e.clientX, e.clientY);
});
joystickArea.addEventListener("pointermove", function(e) {
if (joystickActive) {
updateJoystick(e.clientX, e.clientY);
}
});
joystickArea.addEventListener("pointerup", function(e) {
joystickActive = false;
centerJoystick();
});
joystickArea.addEventListener("pointercancel", function(e) {
joystickActive = false;
centerJoystick();
});
// ===== ESC 油門控制 =====
throttle.addEventListener("input", function() {
throttleUs = parseInt(throttle.value);
updateDisplay();
sendControl();
});
// ===== 立即關油門 =====
function stopMotor() {
throttleUs = 1000;
throttle.value = 1000;
updateDisplay();
sendControl();
}
// ===== 心跳訊號 =====
// 即使手指沒有移動,也會定期送出目前控制值。
setInterval(sendControl, 250);
updateDisplay();
centerJoystick();
</script>
</body>
</html>
)rawliteral";
return page;
}
5. 後端 handleSet() 保持原本格式
新版搖桿網頁仍然送出同樣的網址格式:
/set?r=90&e=90&t=1000
因此你原本的 ESP32-CAM 後端可以保持如下:
ESP32-CAM 接收手機控制指令
void handleSet() {
if (server.hasArg("r")) {
rudderAngle = constrain(server.arg("r").toInt(), 0, 180);
}
if (server.hasArg("e")) {
elevatorAngle = constrain(server.arg("e").toInt(), 0, 180);
}
if (server.hasArg("t")) {
throttleUs = constrain(server.arg("t").toInt(), 1000, 2000);
}
rudderServo.write(rudderAngle);
elevatorServo.write(elevatorAngle);
esc.writeMicroseconds(throttleUs);
lastCommandTime = millis();
server.send(200, "text/plain", "OK");
}
6. 常用調整項目
6.1 調整舵面最大角度
在新版網頁程式裡找到:
let servoRange = 45;
這代表搖桿打到底時,Servo 範圍是:
90 - 45 = 45°
90 + 45 = 135°
| 設定值 | Servo 範圍 | 建議用途 |
|---|---|---|
servoRange = 30 | 60°–120° | 初次測試,最安全 |
servoRange = 45 | 45°–135° | 課堂實作建議值 |
servoRange = 60 | 30°–150° | 確認連桿不會卡住後再使用 |
servoRange = 90 | 0°–180° | 不建議初學者直接使用 |
6.2 如果方向舵左右相反
把:
let reverseRudder = false;
改成:
let reverseRudder = true;
6.3 如果升降舵上下相反
把:
let reverseElevator = false;
改成:
let reverseElevator = true;
7. 上課測試流程
學生操作:手機連到自己的
ESP32CAM_RC_XXXX,瀏覽器輸入 http://192.168.4.1,打開控制頁。
1
先只接兩顆 Servo,不接 ESC,測試方向舵與升降舵是否會跟著搖桿動。
2
確認搖桿放手後,方向舵與升降舵都會回到 90° 中立點。
3
確認舵面沒有卡住、連桿沒有過度彎曲,再接 ESC 訊號線。
4
拆掉螺旋槳,再接上電池測試 ESC 油門滑桿。
5
按下「立即關油門」確認馬達會停止。
8. 課堂檢查表
| 檢查項目 | 正常狀態 |
|---|---|
| 手機是否連到自己的 ESP32-CAM | Wi-Fi 名稱應為自己的 ESP32CAM_RC_XXXX |
| 網頁是否能開啟 | 瀏覽器可打開 192.168.4.1 |
| 搖桿中心 | Rudder = 90°,Elevator = 90° |
| 搖桿放手 | 黑色搖桿回中心,兩個 Servo 回中 |
| 油門最低 | ESC = 1000 µs |
| 失控保護 | 手機斷線或沒有指令時,油門應回到 1000 µs |
9. 建議的最終手機配置
第一版建議使用直式手機版:
上方:XY 類比搖桿
下方:ESC 油門滑桿
等學生測試穩定後,可以再改成橫式遙控器風格:
左邊:ESC 垂直油門
右邊:XY 類比搖桿