回到首頁

ESP32-CAM 手機網頁控制:改成 XY 類比搖桿

將原本三個滑桿的控制頁,改成「XY 搖桿控制方向舵與升降舵」+「ESC 油門滑桿」。適合 RC 高翼機、KT 板定翼機與課堂實作。

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 設定、handleSet()loop() 失控保護。
取代
將原本的 String htmlPage() 整段換成下面的搖桿版本。
可調整
修改 servoRangereverseRudderreverseElevator

3. 修改步驟

1
打開原本的 Arduino 程式,找到 String htmlPage()
2
把原本三個滑桿的 HTML 網頁內容整段刪除。
3
貼上第 4 節提供的新版 htmlPage()
4
確認 handleSet() 還是接收 ret 三個參數。
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 = 3060°–120°初次測試,最安全
servoRange = 4545°–135°課堂實作建議值
servoRange = 6030°–150°確認連桿不會卡住後再使用
servoRange = 900°–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-CAMWi-Fi 名稱應為自己的 ESP32CAM_RC_XXXX
網頁是否能開啟瀏覽器可打開 192.168.4.1
搖桿中心Rudder = 90°,Elevator = 90°
搖桿放手黑色搖桿回中心,兩個 Servo 回中
油門最低ESC = 1000 µs
失控保護手機斷線或沒有指令時,油門應回到 1000 µs

9. 建議的最終手機配置

第一版建議使用直式手機版:

上方:XY 類比搖桿
下方:ESC 油門滑桿

等學生測試穩定後,可以再改成橫式遙控器風格:

左邊:ESC 垂直油門
右邊:XY 類比搖桿