FastBond3挑战部分-基于ESP32-S3和DRV8962的物联网FOC步进驱动
该项目使用了ESP32-S3和DRV8962,实现了物联网FOC步进驱动的设计,它的主要功能为:对步进电机进行FOC三闭环控制,可通过多种方式通信。。
标签
MQTT
CAN
FOC
RS485
StreakingJerry
更新2024-09-23
811

创意方案介绍

在这个项目中我制作一款步进电机FOC驱动,可以三闭环控制标准42步进电机,体积小,可以直接安装在步进电机尾部。


作为工业4.0方向应用,该电机驱动支持多种通信方式,比如CAN,MODBUS,RS485,UART以及常见的EN、DIR、STP控制,还要有工业上要求的各种毛刺保护电路。


同时,作为物联网方向应用,该电机驱动还拥有无线控制能力,支持MQTT协议,可使用MQTT对电机进行完全控制。


方案框图介绍

本项目使用的核心模块有两个,其中主控使用ESP32-S3,电机驱动使用的是DRV8962。为了可以自由调整DRV8962上的限流斩波控制阈值,还需要一块MCP4725来输出模拟量信号,控制DRV8962上的Vref。


DRV8962的集成度非常高,上面已经集成了低边电流测量功能,板载再加一个编码器识别电机角度即可。我选择的是AS5047P。


其他的模块是为了一些通信和调试功能服务。


image.png

方案中用到的指定厂商元器件介绍

1,ESP32-S3 是一款低功耗的MCU 系统级芯片(SoC),支持2.4 GHz Wi-Fi 和低功耗蓝牙(Bluetooth® LE) 无线通 信。 芯片集成了高性能的Xtensa® 32 位LX7 双核处理器、超低功耗协处理器、Wi-Fi 基带、蓝牙基带、RF 模块 以及外设。

ESP32-S3-WROOM-1 双核蓝牙WiFi模块-双核ESP32芯片-亿佰特WiFi模块


2,MCP4725是一个低功耗,高精度,单通道,12位缓冲电压输出数字到模拟转换器(DAC)与非易失性存储器(EEPROM)。 它的板载精度输出放大器允许它实现轨到轨模拟输出摆动。 DAC输入和配置数据可以被编程到非易失性存储器(EEPROM)由用户使用I2C接口命令。

Overview | MCP4725 12-Bit DAC Tutorial | Adafruit Learning System


原理图介绍

先看下原理图全貌

image.png

其中,ESP32-S3单片机部分,除了boot和en引脚的按钮以外,还增加了串口以及USB-CDC两个USB接口,方便使用各种不同的方式进行开发调试,增加电路板的可玩性。

image.png


由于步进电机的电压一般都高于12V,降压至3.3V给单片机使用时,压降过大,因此需要使用一款电压范围较大的DC-DC芯片来完成电压转换。我选的是LM5164.

image.png


位置传感器我用的是AS5047P,这款磁编码器在使用SPI通信时可以做到绝对量的输出,而且分辨率高达14位。

image.png


通信芯片除了GPIO直接引出外,我还增加了RS485和CAN通信芯片。选型时需要注意一定要选用支持3.3V的版本。终端电阻已经设计在板上,可以通过跳线帽选择是否接入。

image.png

最核心的部分是这一颗TI的双全桥电机驱动,不但支持很宽的电压范围,而且里面还自带了电流放大器以及低边电流采样电阻,相当于无需任何外部元件就可以实现内部的斩波限流。因为我们要使用FOC来控制电机,自然需要两相的电流检测。由于电流测量值是通过电流的方式来输出的,因此需要一个接地电阻来把电流输出值转化为电压输出值。


电源输入部分我还增加了两颗电解电容。大电解电容的目的不光光是作为一个瞬间大功率的备用电流源使用,更重要的是电解电容具备一定的寄生电阻。如果缺乏这个电阻缓冲,当突然上电的时候,瞬间给陶瓷电容充电,电路中的寄生电感可能会在输入端产生一个非常大的电压毛刺,这个毛刺会烧穿后面的电路。而电解电容的寄生电阻在这里就能充当一个很好的阻尼器。

image.png

恒流斩波是通过Vref引脚来进行设置的。一般情况我们会在板子上放一个电位器来设置。但这个模块我设计初衷是具备物联网控制功能的,所以最好所有的设置都可以通过软件完成,不需要实际接触到电路板。因此我又用了一个I2C串口的DAC来给Vref提供参考电压,设置斩波阈值。

image.png

引出的所有外部引脚都增加了TVS二极管,一般工业应用上都会有这一类的保护要求,用来对高压和脉冲静电进行防护。

image.png

PCB设计介绍

最常用的步进电机是42电机,因此这块板子的尺寸需要和42电机一致。而ESP32-S3模块比较大,因此我只好把通讯芯片和电源芯片这些高度不太高的芯片放在背面。焊接时两面都有元件。

image.png

image.png

3D图可以直观看出,四边中其中一个边用作大电流部分,电源输入以及给电机供电,使用螺丝端子进行连接;另一边是各种通讯信号线,也是用螺丝端子连接;第三条边放了两个USB口,用来做调试使用,最后一个边留给ESP32-S3模组的天线。


可以注意到大电流的区域我打了密密麻麻的过孔。这里的布线也非常讲究,不但要短,要大线宽,增加过流能力;更重要的是要尽可能减小环路面积,减少EMI。

image.png

焊接流程及成品展示

制作回来的的电路板非常漂亮

13f05c7396418cfe22edb91484e58b3.jpg


由于正面的元件很多,焊接时可以先把正面的元件用锡膏和铁板烧焊接好,然后再去手工焊接背面的元件。

610017ce1f4293b4ea8cc80f1637979.jpg

c5b776631feb4ab588c239c8f8b2c23.jpg

正面的两个跳线帽是用来接入终端匹配电阻使用的。如果不需要可以像我这样只插一个针,避免需要时找不到跳线帽。

代码介绍

基础代码使用simpleFOC进行构建,开发环境我直接用的是Arduino IDE。

电机配置部分并不复杂,直接使用官方的example就可以,配置好驱动,位置传感器和电流传感器,再把它们连接到电机就行。电机部分的基本参数配置可以先在setup()函数中写好:

// motor config
  motor.voltage_sensor_align = driver.voltage_power_supply * 0.8;
  motor.controller = MotionControlType::angle;


  motor.PID_velocity.P = 0.2;
  motor.PID_velocity.I = 20;
  motor.PID_velocity.D = 0.001;
  motor.PID_velocity.output_ramp = 1000;
  motor.LPF_velocity.Tf = 0.01;


  motor.P_angle.P = 20;
  motor.P_angle.I = 0;
  motor.P_angle.D = 0;
  motor.P_angle.output_ramp = 10000;
  motor.LPF_angle.Tf = 0;



  motor.motion_downsample = 10;

这其中第一行代码非常非常关键。arduinoFOC的官方比较保守,在默认配置的yaml文件中将电机在初始化时的电压(也就是PWM)设置为0.3,避免烧坏电机。但我在实际测试时发现,当使用12V驱动电机时,0.3的默认值根本无法让电机转动,这就会导致初始化频频失败。坑爹的是,不在调试模式下,根本不知道初始化出了问题,只会发现每次上电后电机的表现都不一样(因为初始化失败,simpleFOC估计的参数可以是任何值);而即便打开了debug功能,也只能看到初始化失败,但并不知道为什么。我在这里耗费了大量的时间,希望其他人不再踩坑。


另外一个就是我在最后一行增加的motor.motion_downsample设定。这个主要是为了磁编码器的速度计算来设置的。由于磁编码器的采样率很高,每次采样间隔时间电机转过的角度太小,这就导致计算出的速度波动性过大,基本无法使用。这个问题是我在直接读取传感器数据时发现的, 这里手动把他设置为100,可以让电机很平稳的工作。


代码中最关键的部分是控制模式输入输出部分。simplefoc有一套commander指令,可以对电机参数和指令进行非常完全的控制。但这套指令只能通过Serial Stream buffer使用。而现在我们想让他可以通过RS485, CAN甚至是MQTT控制,不太优雅的方式是直接写个简单的命令,在loop中轮询,或是通过回调函数,来直接set target。但我们当然会使用更优雅的方式,就是直接把整套commander接口暴露到其他的通信端口上。


首先先说传统的DIR, STEP控制。这个在simpleFOC里可以直接实现,比较简单。

StepDirListener step_dir = StepDirListener(2, 42, _2PI / 200.0);
void onStep() {
  step_dir.handle();
}

void setup() {
  // init step and dir pins
  step_dir.init();
  step_dir.enableInterrupt(onStep);
  step_dir.attach(&motor.target);
}


RS485也是串口通讯的一种,只是在电平上采用了差分方式,因此两根线只能半双工通讯。所以这个也比较好实现,我们只要把Serial1配置为半双工模式,并且使用接在485芯片上的引脚就可以实现。

// instantiate the RS485 interface
#define RS485_RX_PIN 18
#define RS485_TX_PIN 17
#define RS485_RTS_PIN 38
#define RS485_BAUD 9600
#define RS485 Serial1
Commander commander_rs485 = Commander(RS485);
void onMotor_rs485(char* cmd) {
  commander_rs485.motor(&motor, cmd);
}

void setup() {
  // init RS485
  RS485.begin(RS485_BAUD, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN);
  while (!RS485) {
    delay(10);
  }
  if (!RS485.setPins(-1, -1, -1, RS485_RTS_PIN)) {
    Serial.print("Failed to set RS485 pins");
  }
  if (!RS485.setMode(UART_MODE_RS485_HALF_DUPLEX)) {
    Serial.print("Failed to set RS485 mode");
  }
  commander_rs485.add('M', onMotor_rs485, "full motor config");
}


而CAN就麻烦的多,这里我们新建一个头文件,基于arduino原本的Stream类创建一个新的子类,然后通过重写原本类中的方法来实现功能。在我们的主程序中就可以把这个子类作为参数传递给Commander来进行使用。由于CAN一次只能传送8个字节的数据,指令都在8字节以内,但指令运行成功的回报信息都在8字节以上,因此我对回报打印的函数进行了一些修改,只要发现有回报内容,说明指令执行成功,那么CAN只需要返回一个1就可以代表指令执行成功。

#ifndef CanSerial_h
#define CanSerial_h
#include <ESP32-TWAI-CAN.hpp>


class CanSerial : public Stream {
public:


  CanSerial() {}


  ~CanSerial() {}


  bool begin(uint8_t CAN_RX_PIN, uint8_t CAN_TX_PIN, uint16_t CAN_SPEED, uint8_t CAN_ID) {
    _ID = CAN_ID;
    txFrame.identifier = _ID;
    txFrame.extd = 0;
    txFrame.data_length_code = 1;
    txFrame.data[0] = 1;
    return ESP32Can.begin(ESP32Can.convertSpeed(CAN_SPEED), CAN_TX_PIN, CAN_RX_PIN, 10, 10);
  }


  int available() override {
    if (_available) { return 1; }
    if (ESP32Can.readFrame(rxFrame, 0)) {
      if (rxFrame.identifier == _ID) {
        _available = 1;
        return rxFrame.data_length_code;
      }
    }
    return 0;
  }


  int read() override {
    if (_available) {
      if (_pos < rxFrame.data_length_code) {
        return rxFrame.data[_pos++];
      } else {
        _pos = 0;
        _available = 0;
        return '\n';
      }
    } else {
      return -1;
    }
  }


  int peek() override {
    if (_available) {
      return rxFrame.data[_pos];
    } else {
      return -1;
    }
  }


  size_t write(uint8_t byte) override {
    if (byte != '\r' && byte != '\n') {
      _buffer_tx[_pos++] = byte;
    } else if (byte == '\n') {
      ESP32Can.writeFrame(txFrame);
      Serial.print("CAN:");
      Serial.println(_buffer_tx);
      memset(_buffer_tx, 0, sizeof(_buffer_tx));
      _pos = 0;
    }
    return 1;
  }


private:
  CanFrame rxFrame;
  CanFrame txFrame;
  uint8_t _ID;
  uint8_t _pos = 0;
  bool _available = 0;
  char _buffer_tx[64] = { 0 };
};


#endif


最后MQTT的实现方式与CAN类似。唯一的区别是MQTT不再有字节数限制,因此我们可以把返回的数据原原本本的通过MQTT Publish出去。

#ifndef MQTTSerial_h
#define MQTTSerial_h
#include <WiFi.h>
#include <PubSubClient.h>


class MQTTSerial : public Stream {
public:
  MQTTSerial()
    : _MQTTclient(_wifiClient) {}


  ~MQTTSerial() {}


  void loop() {
    reconnect();
    _MQTTclient.loop();
  }


  void begin(const char* ssid,
             const char* password,
             const char* mqtt_server,
             const int port,
             const char* MQTT_usr,
             const char* MQTT_pwd,
             const char* client_ID,
             const char* topic_rx,
             const char* topic_tx) {
    _MQTT_usr = MQTT_usr;
    _MQTT_pwd = MQTT_pwd;
    _client_ID = client_ID;
    _topic_rx = topic_rx;
    _topic_tx = topic_tx;


    WiFi.begin(ssid, password);
    while (WiFi.status() != WL_CONNECTED) {}
    _MQTTclient.setServer(mqtt_server, port);
    _MQTTclient.setCallback([this](char* topic, byte* payload, unsigned int length) {
      this->callback(topic, payload, length);
    });
  }


  int available() override {
    return _available;
  }


  int read() override {
    if (_available) {
      if (_pos < _length) {
        return _buffer_rx[_pos++];
      } else {
        _pos = 0;
        _available = 0;
        return '\n';
      }
    } else {
      return -1;
    }
  }


  int peek() override {
    if (_available) {
      return _buffer_rx[_pos];
    } else {
      return -1;
    }
  }


  size_t write(uint8_t byte) override {
    if (byte != '\r' && byte != '\n') {
      _buffer_tx[_pos++] = byte;
    } else if (byte == '\n') {
      _MQTTclient.publish(_topic_tx, _buffer_tx);
      Serial.print("MQTT:");
      Serial.println(_buffer_tx);
      memset(_buffer_tx, 0, sizeof(_buffer_tx));
      _pos = 0;
    }
    return 1;
  }



private:
  WiFiClient _wifiClient;
  PubSubClient _MQTTclient;
  const char* _MQTT_usr;
  const char* _MQTT_pwd;
  const char* _client_ID;
  const char* _topic_rx;
  const char* _topic_tx;
  uint8_t _pos = 0;
  bool _available = 0;
  unsigned char* _buffer_rx;
  char _buffer_tx[64] = { 0 };
  uint16_t _length;


  void reconnect() {
    if (!_MQTTclient.connected()) {
      if (_MQTTclient.connect(_client_ID, _MQTT_usr, _MQTT_pwd)) {
        _MQTTclient.subscribe(_topic_rx);
      }
    }
  }


  void callback(char* topic, byte* message, unsigned int length) {
    _available = 1;
    _buffer_rx = message;
    _length = length;
    // Serial.print("Message arrived on topic: ");
    // Serial.print(topic);
    // Serial.print(". Message: ");
    // for (int i = 0; i < _length; i++) {
    //   Serial.print((char)_buffer_rx[i]);
    // }
    // Serial.println();
  }
};


#endif

功能展示

42电机,最好直接使用这种电机尾轴上带有径向磁铁的。如果没有,自己买径向磁铁,然后粘到后轴上面也可以。

1d4ec8edcbee6fbe0d18e52cf1f3023.jpg


磁编码器正好放在磁铁上方,连接好电机和驱动器,就完成了全部装配。电机和驱动器之间需要放一个合适长度的隔离柱,确保引脚不要接触到电机,但同时也不能让磁编码器距离径向磁铁过远。

d2c2fa14ae034fd471641a96d53e5af.jpg

d032a64e94c93b02f20cd60b8490dd2.jpg

RS485通讯测试,可以使用一块arduino单片机与485模块制作一个调试器来完成。调试器的代码我会包含在项目文件中。

6de2e26e836c3450e71f86de25f76a8.jpg


CAN通讯测试,我使用的是稚晖君的PicoDK开发板来充当调试器使用,因为这颗开发板上自带了CAN通信芯片。调试器的代码我会包含在项目文件中。

82340f25059b0be02556cd9c64bdacb.jpg


具体的功能演示,由于需要观察电机的动态情况,图片难以展示,大家可以直接参考视频中的内容。


心得体会

我一直对自己DIY一台工业机械臂有兴趣,而最近又突然有需求需要制作一个小巧便携的智能家居窗帘电机。这次活动一次性满足我的这两个需求。经过这次活动的制作,不但熟练了硬件设计,选型与焊接技能,更让我第一次仔细去研究arduino中的方法,通过重写原生库来实现功能拓展,收获非常大。希望像Fastbond这样灵活的创意活动越办越好!

附件下载
foc_stepper_driver.zip
团队介绍
个人
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号