自制遥控机械臂
自适应iframe高度
Passkey开发指南
一文搞懂Passkey
教育年金险
快返年金险
养老年金险
增额终身寿险
终身寿险
定期寿险
Web3全栈开发指南
期权交易
套利原理
永续合约
混合保证金
反向合约原理
期货交易
算法稳定币
Uniswap入门
做市指南
ETF指南
流动性概述
现货交易
元宇宙落地指南
用FPGA写Hello World
兵马俑
骊山游记
JDK11新特性解读
挑战2016北京马拉松
秋游红叶岭
纯CSS气泡效果
Python yield使用浅析
快速排序算法
JSON入门指南
J2ME概念解析

自制遥控机械臂

廖雪峰
资深软件开发工程师,业余马拉松选手。

今年双十一入手了一台3D打印机,我从网上下载了好几个模型,打印效果非常不错:

Train

然而,总是下载别人设计好的模型直接打印,感觉没有发挥出我自身的设计能力。因此我决定自己动手,设计3D模型。

在网上看了一圈科普,发现3D模型设计大概分两类:

  1. 使用ZBrush、Blender这样的建模软件创作艺术类模型,主要用于电影、游戏、手办等;
  2. 使用SolidWorks、Fusion360这样的工业建模软件创建精确的工业产品模型,可以设计家具、生活用品、机器人等。

考虑到我自己的艺术天赋有限,自然是选择更加困难的工业设计。因此,为了能设计3D模型并用3D打印机打印出来,我决定先掌握SolidWorks这个工业设计软件。

在B站上搜了一下SolidWorks教程,发现阿奇老师的SolidWorks教程很受欢迎,我花了一周的时间快速学习了一下,顺利设计出小抽屉、摇头小风扇:

3D Models

总结一下SolidWorks建模的核心,就是掌握草图绘制,制作零件,最后用装配体完成装配并模拟机械运动,检查确保没有干涉后,完成最终3D模型。

SolidWorks,轻松拿下!

接下来要挑战高难度的机器人设计,虽然我的最终目标是人形机器人,但还是要遵循循序渐进的原则,先从入门级的机械臂开始,正好阿奇老师还有一门收费的机械臂课程,再花一周时间快速学习,顺利完成机械臂模型:

Robotic Arm

和课程原版机械臂相比,主要改动如下:

  1. 去掉同步带轮,改成齿轮驱动;
  2. 简化了一下夹爪,用3个齿轮驱动。

遗憾的是,这个教程虽然能实现最终的机械臂设计,但并没有完成MCU控制与软件操控的功能。不过,这就到咱的专业领域了。

B站上有很多操控机械臂的例子,最常见的是通过电脑连USB线,或者手机来操作。然而,用鼠标或者触控屏幕来控制舵机,只能在开发阶段用,作为产品,是完全不合格的。要轻松地操控机械臂,必须使用无线手柄遥控。因此,我梳理了一下机械臂的产品需求如下:

  1. 使用无线手柄遥控机械臂,无需借助电脑或手机作为主机中转;
  2. 必须内置MCU主控芯片,并且需要支持无线连接;
  3. 如果使用WiFi连接,则需要接入一个现有的无线网并配置IP,不如蓝牙方便易用。

所以,最终方案确定使用蓝牙手柄,通过蓝牙连接到MCU主控,实现操控机械臂。

现在问题来了,MCU芯片哪家强?

首先排除掉上古时代的51,剩下的选手包括:

  • STM32:使用ARM Cortex-M 32位CPU;
  • Arduino:使用AVR单片机,也有用ARM的产品;
  • Pico:树莓派出的自研MCU;
  • ESP32:国产MCU,使用LX7或RISC-V的32位CPU。

比较上述产品,我决定最终选择ESP32,因为它不仅是国产精品,而且在一个芯片里集成了WiFi和蓝牙,以及各种外设通信协议,性价比极高!这意味着不需要任何额外的模块,就可以直接实现蓝牙连接!

在淘宝上找到最便宜的ESP32C3 Mini开发板,价格仅10~20元左右,可以说完全吊打STM32、Arduino和Pico:

esp32c3

因此,MCU主控就选择ESP32C3,外加一个舵机驱动板PCA9685,一个蓝牙手柄(型号待定)。

pca9685

这里说明一下,为什么我们需要一个单独的舵机驱动板。因为我们这个机械臂使用的是非常便宜的模拟舵机,通过PWM信号控制。虽然ESP32本身可以输出PWM信号,但是,它内部的PWM引脚是有限的,而PCA9685可以同时输出16路PWM信号,也就是可以同时驱动16个舵机!如果需要驱动更多的舵机,还可以级联扩展。此外,驱动多个舵机需要给舵机稳定地供电,不能从ESP32的板子上引出电源,而PCA9685驱动板自带供电输入,免去了我们再单独找一个电源板的麻烦。

ESP32+PCA9685控制舵机的原理如下:

ESP32通过I2C接口向PCA9685发送控制命令,PCA9685会根据控制命令生成PWM信号来控制舵机,这样,我们在编写程序时,只需发送控制命令,无需使用定时器来生成PWM信号,能大大简化控制程序的开发。

接下来要选择蓝牙手柄。在仔细阅读了ESP32C3的相关文档后,我发现蓝牙协议还挺复杂的,最早的蓝牙1.0诞生于1999年,而现在广泛使用的蓝牙4.x和5.x分别诞生于2010年和2016年,它们与之前版本的重要区别在于引入了低功耗(Low Energy)蓝牙,简称BLE(Bluetooth Low Energy),而传统蓝牙被称为经典蓝牙(Classic BR/EDR),这两种协议采用的协议栈几乎不重叠,可以把它俩看作是两种不同的协议。

因此,在涉及到支持蓝牙的具体硬件时,它可以支持以下任意一种或多种协议:

  • BR/EDR 4.x;
  • BR/EDR 5.x;
  • BLE 4.x;
  • BLE 5.x。

手机和电脑通常会同时支持BR/EDR和BLE(即双模蓝牙),但蓝牙外设如鼠标、耳机、闹钟等,一般仅支持某一种协议。根据ESP32的官方选型手册,ESP32C3仅支持BLE 5.0,因此,我们必须选择支持BLE 5.0的蓝牙手柄。目前,市面上大多数的游戏手柄仅支持经典蓝牙,我在淘宝上问了一圈客服,得到肯定答复的只有这一家名叫盖世小鸡的手柄,因此,最终选择他家的启明星无线手柄,不到100元拿下:

joystick

软件开发环境

针对ESP32,可以选择的开发环境如下:

  • 使用官方的ESP-IDF配合Eclipse或VSCode,编译器采用GCC;
  • 使用Arduino IDE,使用简化的C++;
  • 使用MicroPython。

如果使用MicroPython,那么通常我们是在应用层开发,这要求固件包含基本的系统和MicroPython运行环境。使用MicroPython最大的问题是,如果要使用的硬件没有现成的驱动,那么我们仍然要用C为MicroPython编写调用接口。此外,MicroPython的运行效率比C要差很多。

使用Arduino IDE开发时,最大的优势是可以使用Arduino丰富的第三方库,缺点是Arduino本身太简单了,简单到连操作系统都没有,入门容易,但并不便于开发复杂的程序。而ESP32官方提供的ESP-IDF开发环境,用CMake和GCC编译,运行在FreeRTOS系统上,可以说开发难度最高,但也最灵活,因为我们可以完全控制底层。

综上分析,我们选择难度拉满,使用ESP-IDF和VSCode开发环境,直接写C代码。这里不需要用到C++是因为C++的虚函数和模板这些高级功能在控制硬件时几乎用不上。

连接蓝牙手柄

在蓝牙协议中,互相连接的两个设备被称为Client和Server,但是与互联网的Client/Server相反,蓝牙的Client是PC或手机,Server是耳机等配件。另一种更好的说法是主机(Central)和外设(Peripheral),主机就是PC或手机,外设就是键盘、鼠标、手柄、传感器等。一个主机可以同时和多个外设实现蓝牙连接,但一个外设在某个时间段内只能连接到一个主机。

蓝牙建立连接的过程如下:

  1. 外设首先要主动向外广播;
  2. 主机要主动发起扫描,发现正在广播的外设后发起连接请求;
  3. 外设接受请求后,通过配对建立连接,开始通信;
  4. 连接从建立到断开期间,外设不再广播。

蓝牙BLE协议实际上比经典蓝牙BR/EDR要简单很多,BLE协议定义了外设能提供的服务(Service),每一个服务都可以通过枚举列出特征(Characteristic),特征分为可读、可写、可订阅等类别,一个温度传感器的某个特征如温度就是可读的,一个闹钟的某个特征如当前时间就是可读可写的,一个手柄的控制按钮就是可订阅的,即用户按下某个键后主机会收到订阅的消息。这些服务和特征用UUID标识,蓝牙标准组织定义了很多通用的ID来标识键盘、鼠标、手柄、温度、湿度、心率等各类传感器,要控制手柄,我们假设厂商预设的ID是符合标准的,所以只需要根据手柄的标准ID就可以读取手柄的输入。

微软官方提供了一个Bluetooth LE Explorer,可以直接从Windows应用商店安装,这样我们可以方便地查看蓝牙BLE手柄的信息:

BLE

ESP32C3芯片的角色就是主机,所以我们需要以Host模式启动ESP32C3的蓝牙功能。也可以以外设模式启动ESP32的蓝牙,用外设模式时,我们实际上可以基于ESP32自己开发一个蓝牙手柄或者任何蓝牙外设。

在网上能找到的ESP32连接蓝牙手柄的资料非常少,但好消息是ESP-IDF自带了一个使用Host模式连接HID外设的例子esp-hid-host,HID就是人体工学输入设备(Human Interface Device),手柄、鼠标、键盘都属于HID,直接新建一个ESP-IDF工程并选择使用该模板,我们就得到了一个自动连接手柄的程序。

根据ESP32的官方文档,ESP32支持两种软件协议栈:

  • Bluedroid:这是Android系统开源的一个蓝牙协议栈,支持BR/EDR和BLE;
  • Nimble:这是Apache开源的一个蓝牙协议栈,它仅支持BLE。

该示例默认使用Bluedroid,也可以改为Nimble,但这里既然跑通了Bluedroid,我就懒得测试Nimble了。

打开手柄并开启配对模式(主动广播),再运行代码,可以自动扫描到名字为Xbox Wireless Controller的手柄,然后自动配对连接,接下来就会不停地打印出手柄的输入:

00 80 ff 7f 00 80 ff 7f 00 00 00 00 00 00 00 00

每次接收的输入数据是16字节,猜测这16字节包含了手柄所有键的状态。我们简单按下手柄不同的按钮和摇杆,就能观察到16字节输入的变化,可以确定各个字节的数据表示如下:

  • 第0~3字节:左摇杆的X、Y轴数据;
  • 第4~7字节:右摇杆的X、Y轴数据;
  • 第8~9字节:左上L2按钮的力度;
  • 第10~11字节:右上R2按钮的力度;
  • 第13字节:A、B、X、Y、L1、R1按钮;
  • 第14字节:View和Menu按钮;
  • 其他字节:暂不关心。

摇杆的X、Y轴数据分别用两个小端序的uint16表示,范围是0~0xffff

(0x0, 0x0)           (0xffff, 0x0)
    ┌───────────────────────┐
    │                       │
    │                       │
    │                       │
    │                       │
    │                       │
    │           x           │
    │    (0x8000, 0x7fff)   │
    │                       │
    │                       │
    │                       │
    │                       │
    └───────────────────────┘
(0x0, 0xffff)        (0xffff, 0xffff)

当摇杆位于中心时,X的坐标是0x8000,Y的坐标是0x7fff,这一点比较奇怪,虽然只差1

L2按钮的力度用两个uint8表示,第一个uint8范围是0~0xff,表示精确的力度,第二个uint8的范围是0~3,表示较粗的力度。R2同理。

A、B、X、Y、L1和R1编码如下:

  • A = 0x01
  • B = 0x02
  • X = 0x08
  • Y = 0x10
  • L1 = 0x40
  • R1 = 0x80

View和Menu按钮编码如下:

  • View = 0x04
  • Menu = 0x08

因此,我们可以直接解析这16字节如下:

typedef struct __attribute__((packed)) { // __attribute__((packed))是gcc扩展,表示紧凑字段,不做内存对齐
    uint16_t left_stick_x; // 左摇杆
    uint16_t left_stick_y;
    uint16_t right_stick_x; // 右摇杆
    uint16_t right_stick_y;
    uint8_t left_trigger; // 左上L2按钮
    uint8_t left_trigger_level;
    uint8_t right_trigger; // 右上R2按钮
    uint8_t right_trigger_level;
    uint8_t any_1;
    uint8_t buttons; // A,B,X,Y,L1,R1按钮
    uint8_t ex_buttons; // VIEW, MENU按钮
    uint8_t any_2;
} ctrl_input_data_t;

接下来在示例代码的基础上做如下修改。

默认的示例代码会自动连接扫描到的最后一个HID输入设备,我们改为按名字过滤,仅连接到名字为Xbox Wireless Controller的设备。注意这个名字是通过Bluetooth LE Explorer看到的,如果你的手柄不是这个名字,就改成实际的手柄名字。

然后定义连接状态:

enum CTRL_STATE
{
    SCANNING, // 正在扫描
    OPENING, // 正在打开
    INPUT // 正常输入
};

当未扫描到手柄时,默认的示例代码会自动停止运行,我们改为暂停5秒后继续扫描,死循环不退出:

void hid_scan_task(void *pvParameters)
{
    for (;;) {
        vTaskDelay(5000 / portTICK_PERIOD_MS);
        if (ctrl_state == SCANNING) {
            hid_scan();
        }
    }
}

当连接到手柄后,我们看到接收手柄数据的频率非常快,需要对输入数据按200ms的间隔采样,即每秒钟只接受5次输入:

case ESP_HIDH_INPUT_EVENT: {
    // 处理手柄输入:
    const uint8_t *bda = esp_hidh_dev_bda_get(param->input.dev);
    if (param->input.length == CTRL_INPUT_DATA_LENGTH) {
        // 每200ms采样输入:
        int64_t now = esp_timer_get_time();
        if (now - ctrl_input_time > 200000) {
            // 复制输入数据并发送消息到队列:
            memcpy(&ctrl_input_data, (char *) param->input.data, CTRL_INPUT_DATA_LENGTH);
            xQueueSend(ctrl_input_queue, &ctrl_input_data, 0); // 如果上一次的输入数据还未处理,则不等待直接丢弃
            ctrl_input_time = now;
        }
    }
    break;
}

这样我们就可以在另一个任务中从队列取出手柄输入并处理:

void control_task(void *pvParameters)
{
    // 从队列取出手柄信号并控制舵机:
    ctrl_input_data_t input;
    for (;;) {
        if (pdTRUE == xQueueReceive(ctrl_input_queue, &input, 0)) {
            // TODO: 处理输入
        }
        // 每读取一次输入后,等待100ms,即每秒仅允许10次输入
        vTaskDelay(100 / portTICK_PERIOD_MS);
    }
}

现在,蓝牙手柄的连接和输入问题基本解决,下一步是准备通过I2C接口驱动PCA9685并控制舵机。

在网上找了一圈,发现一个用PCA9685控制LED灯的驱动,稍做改造,变成一个控制舵机的驱动,核心函数如下:

// 设置PCA9685的频率:
void pca9685_set_freq(uint16_t freq)
{
    // Set prescaler
    // calculation on page 25 of datasheet
    uint8_t prescale_val = CLOCK_FREQ / (4096 * freq) - 1;    
    // The PRE_SCALE register can only be set when the SLEEP bit of MODE1 register is set to logic 1.
    uint8_t mode1Reg;
    uint8_t any;
    ESP_ERROR_CHECK(generic_read_two_i2c_register(PCA9685_MODE1_REG, &mode1Reg, &any));
    mode1Reg = (mode1Reg & ~PCA9685_MODE1_RESTART) | PCA9685_MODE1_SLEEP;
    generic_write_i2c_register(PCA9685_MODE1_REG, mode1Reg);
    ESP_ERROR_CHECK(generic_write_i2c_register(PCA9685_PRESCALE_REG, prescale_val));
    // It takes 500us max for the oscillator to be up and running once SLEEP bit has been set to logic 0.
    mode1Reg = (mode1Reg & ~PCA9685_MODE1_SLEEP) | PCA9685_MODE1_RESTART;
    ESP_ERROR_CHECK(generic_write_i2c_register(PCA9685_MODE1_REG, mode1Reg));
    vTaskDelay(5 / portTICK_PERIOD_MS);
}

// 设置指定端口的PWM:
void pca9685_set_channel_pwm(uint8_t channel, uint16_t pwm)
{
    uint8_t pinAddress = PCA9685_LED0_REG + (channel << 2);
    ESP_LOGI(TAG, "set channel %d (addr = %d) pwm: %d", channel, pinAddress, pwm);
    ESP_ERROR_CHECK(generic_write_i2c_register_two_words(pinAddress, 0, pwm));
}

使用PWM信号控制舵机的原理非常简单,PWM是一种占空比信号,以常用的180°舵机为例,它要求PWM信号周期为20ms,即50Hz的频率,根据高电平持续时间决定舵机的偏转角度:

  • 高电平持续0.5ms,低电平持续20-0.5=19.5ms,舵机旋转角度为0°;
  • 高电平持续1.5ms,低电平持续20-1.5=18.5ms,舵机旋转角度为90°;
  • 高电平持续2.5ms,低电平持续20-2.5=17.5ms,舵机旋转角度为180°。

控制高电平持续时间的范围0.5~2.5ms,我们就可以在0~180°范围内控制舵机。

PCA9685输出PWM的原理是针对每个舵机通道,内部有两个寄存器,分别存储open(开启高电平)和close(开启低电平)的计数器周期。计数器从0~4095反复计数,假设open=0close=2047,则每个计数周期我们会得到如下的信号:

   ┌─────────────┐             ┌─────────────┐             ┌───
   │             │             │             │             │
───┘             └─────────────┘             └─────────────┘
   0         2047 2048     4095 0        2047 2048     4095 0

可见上述信号占空比恰好为50%。

如果open=0close=1023,则我们可以得到占空比为25%的PWM信号:

   ┌──────┐                    ┌──────┐                    ┌───
   │      │                    │      │                    │
───┘      └────────────────────┘      └────────────────────┘
   0  1023 1024            4095 0 1023 1024            4095 0

如果open=100close=1123,输出的PWM信号占空比仍为25%,但相位变了:

     ┌──────┐                    ┌──────┐                    ┌───
     │      │                    │      │                    │
─────┘      └────────────────────┘      └────────────────────┘
 0 100  1123 1124       4095 0 100  1123 1124       4095 0 100

有些需要用到相位的控制就可以设定open,这里控制舵机我们不需要相位偏移,因此open始终设置为0,根据close就可方便地计算占空比:

  • 要控制舵机到0°,占空比为0.5/20,计算close=0.5*4096/20-1=101
  • 要控制舵机到90°,占空比为1.5/20,计算close=1.5*4096/20-1=306
  • 要控制舵机到180°,占空比为2.5/20,计算close=2.5*4096/20-1=511
  • 要控制舵机到x°,占空比为(2.5-0.5)*x/180+0.5,计算close=((2.5-0.5)*x/180+0.5)*4096/20-1

因此,我们输入open=0close为根据目标角度计算的整数值,就可以控制舵机角度(注意:存在一点误差)。

下一个问题是,舵机要求的PWM信号周期是50Hz,而PCA9685自带的时钟频率高达25MHz,因此,输出的PWM信号周期是25M/4096=6.1KHz,显然不满足50Hz的要求。

解决这个问题是使用分频器,2分频可以把25MHz降为12.5MHz,4分频可以把25MHz降为6.25MHz,而我们需要的频率是50*4096=204.8KHz,分频器计算为25M/204.8K≈122

因此,当我们调用pca9685_set_freq(50)设置50Hz的频率时,我们并不是把50写入PCA9685,而是先计算分频器的数值,再把这个数值写入PCA9685的寄存器。当我们写入122时,PCA9685内部时钟频率仍为25MHz,但每122个时钟周期才会驱动一次计数,完成从04095的完整计数需要122*4096=499712≈500K个时钟周期,正好对应25M/500K=50Hz

数字芯片内部的寄存器都是整数,我们并不能精确地把频率控制到50Hz。PCA9685官方手册给出的分频器计算公式为:

最后是根据输入的舵机角度计算PWM的close并设置PCA9685的对应寄存器:

// 输入舵机的角度0~180:
void set_servo_pwm(uint8_t channel, int16_t angle)
{
    // 计算占空比:
    uint32_t pwm = RA_SERVO_PWM_RANGE * (uint32_t)angle / RA_SERVO_ANGLE_RANGE + RA_SERVO_PWM_MIN;
    // 设置占空比:
    pca9685_set_channel_pwm(channel, (uint16_t) pwm);
}

联调后刷入到ESP32C3,上电测试,如果一切正常,就可以将舵机和板子安装到机械臂中。

现在,用蓝牙手柄遥控机械臂,看看最终效果:

源码

本项目的SolidWorks设计模型和控制代码完全开源,需要的同学自取:

https://github.com/michaelliao/robotic-arm




Comments

Loading comments...