Skip to content

esp8266 通过 web 服务控制机械臂

发布日期:2023-11-03

本文介绍如何编写 c++ 程序启动 esp8266 的局域网服务,通过接口调用控制机械臂舵机的操作。同时,利用上篇文章所介绍的静态网站服务,提供 pc 端和移动端控制页面。

esp8266机械臂


套件介绍

试验用机械臂由4个舵机、激光切割的木板、螺丝和螺母组成,不含舵机的机械臂仅售 9.9 元,便宜又好玩。由于要驱动4个舵机运行,根据上次的文章 arduino 操作舵机和供电 舵机需要独立供电,因此我使用一节 18650 电池作为单独供电,电池的电压范围是 3.7-4.2v 正好可以驱动舵机;单片机则使用 typec 接口供电。

pc 端机械臂控制页面

通过监听按键事件,分别为上下左右按键和wasd按键绑定事件逻辑,按下按键是发送相应的网络请求。esp8266接收到对应请求后执行舵机角度旋转。

移动端机械臂控制页面

esp8266机械臂移动端控制页面

运行效果

esp8266机械臂移动端控制页面

编写机械臂控制程序

卖家没有提供控制代码,而且控制机械臂的单片机多种多样,下面通过编写 c++ 程序为 esp8266 开启局域网服务,控制4个舵机的旋转角度,从而控制机械臂的运作。

C++
#include <Arduino.h>
#ifdef ESP32
#include <WiFi.h>
#include <AsyncTCP.h>
#elif defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESPAsyncTCP.h>
#endif
#include <ESPAsyncWebSrv.h>
#include <LittleFS.h>
#include <Servo.h>

/*
  Replace the SSID and Password according to your wifi
*/
#ifndef STASSID
#define STASSID "你的wifi名称"
#define STAPSK  "你的wifi密码"
#endif

const char* ssid = STASSID;
const char* password = STAPSK;
Servo s1; // 底盘 
Servo s2; // 左边舵机
Servo s3; // 右边舵机
Servo s4; // 夹子
int deg1 = 90;
int deg2 = 90;
int deg3 = 90;
int deg4 = 60; // 夹子60送-40夹紧
int deg4Max = 60;
int deg4Min = 35;
int deg4Default = 60;



// Webserver
AsyncWebServer server(80);

String PARAM_MESSAGE = "status";

const int LED_PIN = D5;

void notFound(AsyncWebServerRequest *request)
{
  request->send(404, "text/plain", "Not found");
}

void toggleLED(String status)
{
  if (status == "ON")
    digitalWrite(LED_PIN, LOW);
  else
    digitalWrite(LED_PIN, HIGH);
}

void turn(Servo &servo, const String direction, int &deg, bool is4 = false) {
  // 4号夹子特殊逻辑
  if (is4) {
    if (direction == "left" && deg > deg4Min) {
      deg -= 1;
      servo.write(deg);
    } else if (direction == "right" && deg < deg4Max) {
      deg += 1;
      servo.write(deg);
    } else if (direction == "reset") {
      deg = deg4Default;
      servo.write(deg);
    }
    return;
  }
  if (direction == "left" && deg > 0) {
    deg -= 2;
    servo.write(deg);
  } else if (direction == "right" && deg < 180) {
    deg += 2;
    servo.write(deg);
  } else if (direction == "reset") {
    deg = 90;
    servo.write(deg);
  }
}

void setup()
{
  // 舵机
  s1.attach(D1);
  s2.attach(D2);
  s3.attach(D3);
  s4.attach(D4);
  s1.write(deg1);
  s2.write(deg2);
  s3.write(deg3);
  s4.write(deg4);

  Serial.begin(115200);
  Serial.println("Starting the LittleFS Webserver..");

  // Begin LittleFS
  if (!LittleFS.begin())
  {
    Serial.println("An Error has occurred while mounting LittleFS");
    return;
  }

  // Connect to WIFI
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  if (WiFi.waitForConnectResult() != WL_CONNECTED)
  {
    Serial.printf("WiFi Failed!\n");
    return;
  }

  Serial.print("IP Address: ");
  Serial.println(WiFi.localIP());

  // LED PIN
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, HIGH);

  // Route for root index.html
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request)
            { request->send(LittleFS, "/index.html", "text/html"); });

  // Route for root index.css
  server.on("/index.css", HTTP_GET, [](AsyncWebServerRequest *request)
            { request->send(LittleFS, "/index.css", "text/css"); });

  // Route for root entireframework.min.css
  // server.on("/entireframework.min.css", HTTP_GET, [](AsyncWebServerRequest *request)
  //           { request->send(LittleFS, "/entireframework.min.css", "text/css"); });

  // Route for root index.js
  // server.on("/index.js", HTTP_GET, [](AsyncWebServerRequest *request)
  //           { request->send(LittleFS, "/index.js", "text/javascript"); });

  

  server.on("/s1", HTTP_GET, [](AsyncWebServerRequest *request){
    String direction;
    String message;
    if (request->hasParam("direction")) {
        direction = request->getParam("direction")->value();
        turn(s1, direction, deg1);
        // if (direction == "left" && deg1 > 0) {
        //   deg1 -= 10;
        //   s1.write(deg1);
        //   message = "turn left, now is " + deg1;
        // } else if (direction == "right" && deg1 < 180) {
        //   deg1 += 10;
        //   s1.write(deg1);
        //   message = "turn right, now is " + deg1;
        // }
        // message = "good";
    } else {
        deg1 = 90;
        s1.write(deg1);
        message = "error";
    }
    request->send(200, "text/plain", "servo1: " + message);
  });

  server.on("/s2", HTTP_GET, [](AsyncWebServerRequest *request){
    String direction;
    String message;
    if (request->hasParam("direction")) {
        direction = request->getParam("direction")->value();
        turn(s2, direction, deg2);
    } else {
        message = "need param direction";
    }
    request->send(200, "text/plain", "servo2: " + message);
  });

  server.on("/s3", HTTP_GET, [](AsyncWebServerRequest *request){
    String direction;
    String message;
    if (request->hasParam("direction")) {
        direction = request->getParam("direction")->value();
        turn(s3, direction, deg3);
    } else {
        message = "need param direction";
    }
    request->send(200, "text/plain", "servo3: " + message);
  });

  server.on("/s4", HTTP_GET, [](AsyncWebServerRequest *request){
    String direction;
    String message;
    if (request->hasParam("direction")) {
        direction = request->getParam("direction")->value();
        turn(s4, direction, deg4, true);
    } else {
        message = "need param direction";
    }
    request->send(200, "text/plain", "servo4: " + message);
  });

  // Respond to toggle event
  server.on("/toggle", HTTP_GET, [](AsyncWebServerRequest *request)
            {
        String status;
        if (request->hasParam(PARAM_MESSAGE)) {
            status = request->getParam(PARAM_MESSAGE)->value();
            if(status == "on"){
              toggleLED("ON");
            }else{
              toggleLED("OFF");
            }
        } else {
            status = "No message sent";
        }
        request->send(200, "text/plain", "Turning Built In LED : " + status); });

  server.onNotFound(notFound);

  server.begin();
}

void loop()
{
}

编写前端页面

此页面支持 pwa 添加到手机桌面,更接近原生体验。此处省略 css 代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="mobile-web-app-capable" content="yes">
  <meta name="theme-color" content="#8ECAE6">
  <meta name="apple-mobile-web-app-title" content="esp8266">
  <link rel="apple-touch-icon-precomposed" sizes="120x120" href="/test.jpg">
  <link rel="apple-touch-icon" sizes="120x120" href="/test.jpg">
  <title>esp8266</title>
  <link rel="stylesheet" href="index.css">
</head>
<body>
  <!-- <h1 class="title">esp8266</h1> -->

  <div class="mobile">
    <div class="btn-group">
      <div class="btn" data-name="w">前伸</div>
      <div class="btn-middle">
        <div class="btn" data-name="a">夹紧</div>
        <div class="btn" data-name="d">放松</div>
      </div>
      <div class="btn" data-name="s">后缩</div>
    </div>

    <div class="btn btn-reset" data-name="reset">重置</div>

    <div class="btn-group">
      <div class="btn" data-name="up">上抬</div>
      <div class="btn-middle">
        <div class="btn" data-name="left">左转</div>
        <div class="btn" data-name="right">右转</div>
      </div>
      <div class="btn" data-name="down">下压</div>
    </div>
  </div>

  <div class="pc">
    <h2>舵机1底盘转向(← →)</h2>
    <div data-sid="s1" class="btn-group">
      <button data-action="left" >右转</button>
      <button data-action="right" >左转</button>
      <button data-action="reset" >重置</button>
    </div>
  
    <h2>舵机2上下压(↑ ↓)</h2>
    <div data-sid="s2" class="btn-group">
      <button data-action="left" >下压</button>
      <button data-action="right" >上抬</button>
      <button data-action="reset" >重置</button>
    </div>
  
    <h2>舵机3前后伸缩(W S)</h2>
    <div data-sid="s3" class="btn-group">
      <button data-action="left" >后缩</button>
      <button data-action="right" >前伸</button>
      <button data-action="reset" >重置</button>
    </div>
  
    <h2>舵机4夹子(A D)</h2>
    <div data-sid="s4" class="btn-group">
      <button data-action="left" >夹紧</button>
      <button data-action="right" >张开</button>
      <button data-action="reset" >重置</button>
    </div>
  </div>
  

  <script>
    function exec(sid, action) {
      const url = `/${sid}?direction=${action}`
      fetch(url)
    }
    
    const keyMap = {
      up: {
        code: 38,
        servo: 's2',
        action: 'right'
      },
      down: {
        code: 40,
        servo: 's2',
        action: 'left'
      },
      left: {
        code: 37,
        servo: 's1',
        action: 'right'
      },
      right: {
        code: 39,
        servo: 's1',
        action: 'left'
      },
      w: {
        code: 87,
        servo: 's3',
        action: 'right'
      },
      s: {
        code: 83,
        servo: 's3',
        action: 'left'
      },
      a: {
        code: 65,
        servo: 's4',
        action: 'left'
      },
      d: {
        code: 68,
        servo: 's4',
        action: 'right'
      }
    }

    var throttle = function(func, delay) {
        var prev = Date.now();
        return function() {
            var context = this;
            var args = arguments;
            var now = Date.now();
            if (now - prev >= delay) {
                func.apply(context, args);
                prev = Date.now();
            }
        }
    }

    const availableCodes = Object.values(keyMap).map(item => item.code)
    document.addEventListener('keydown', function (e) {
			console.log('keydown', e.keyCode)
      if(availableCodes.includes(e.keyCode)) {
        handleKeyEvent(true, e.keyCode)
      }
		})

    document.addEventListener('touchstart', function (e) {
      for(let touch of e.touches) {
        const dataset = touch.target.dataset
        if(dataset.name === 'reset') {
          fetch('/s1/?direction=reset')
          fetch('/s2/?direction=reset')
          fetch('/s3/?direction=reset')
          fetch('/s4/?direction=reset')
          touch.target.classList.add('btn-active')
          continue
        }

        if(dataset.name && !keyMap[dataset.name].interval) {
          touch.target.classList.add('btn-active')
          const keyItem = keyMap[dataset.name]
          keyItem.interval = setInterval((function sendRequest() {
            fetch(`/${keyItem.servo}?direction=${keyItem.action}`)
            return sendRequest
          })(), 100)
        }
      }
		})

    document.addEventListener('keyup', function (e) {
			console.log('keyup', e.keyCode)
      if(availableCodes.includes(e.keyCode)) {
        handleKeyEvent(false, e.keyCode)
      }
		})

    document.addEventListener('touchend', function (e) {
      const dataset = e.target.dataset
      if(dataset.name && keyMap[dataset.name]?.interval) {
        clearInterval(keyMap[dataset.name].interval)
        keyMap[dataset.name].interval = null
      }
      e.target.classList.remove('btn-active')
		})

    function handleKeyEvent(isDown, keyCode) {
      const keyItem = Object.values(keyMap).find(item => item.code === keyCode)
      if(isDown && !keyItem.interval) {
        keyItem.interval = setInterval((function sendRequest() {
          fetch(`/${keyItem.servo}?direction=${keyItem.action}`)
          return sendRequest
        })(), 100)
      } else if(!isDown) {
        clearInterval(keyItem.interval)
        keyItem.interval = null
      }
    }
  </script>
</body>
</html>

Power by vitepress