尝试做个机械臂(二)

0 阅读9分钟

前言

上篇文章主要介绍了硬件方面的搭建和web网页的上位机控制:尝试做个机械臂(一)。我思考过,如果要让机械臂民用化,一定要做个app来简化控制,比如图形化的动作设计,AI生成动作等。所以,我从零开始做了这个APP,这篇文章将详细介绍了一些想法和经历。

Github

RoboticArm_app
RoboticArm

更新点

1. APP:
此次更新的主要工作内容在于app的制作,实现的功能包含:

  • 机械臂3d模型的渲染和运行
  • 蓝牙的连接和发送数据
  • 滑动块拖动控制机械臂运动
  • 关键帧设计和实现
  • AI(Gemini)生成机械臂动作

2. 3d模型

  • 重画模型

3. 单片机(esp32)端:

  • 新增:接收位置速度数组数据来控制机械臂

先看结果

  1. 通过拨杆(slider)来实时控制机械臂:
    拖动滑块演示.gif

  2. 上篇文章中提到的扇扇子动作,虽然目前还无法精确控制速度(这个有点难度),下一版本或许可以解决。
    由于只有三个电机,但模型是六轴机械臂,所以先简单打印个扇子,用胶布贴一下演示大致动作:
    扇扇子演示.gif

一、 APP

1.简介

技术选型选择了flutter框架。主要页面和功能如下:

APP首页有四个tab:动作列表,控制模块,AI聊天界面和个人中心。 tab页.png

设计动作流程: 设计动作.png

运行动作流程: 运行动作.png AI生成动作: AI生成动作_注释.png

2.关键帧设计

关键帧也是一开始做项目就设计过的,不过当时的数据结构有点冗余。此次做app的过程中将其优化了。那么目前的数据结构如下所示:

@JsonSerializable()
class Motion {
  String id; // 时间戳 + 随机数
  String name; // 存储在sharedreferences动作名称 motion_ 作为前缀
  String description; // 动作描述
  List<String>? imgs; // 图片地址列表
  String? coverImg; //封面地址
  List<Keyframe> keyframes; // 关键帧列表
  String? createTime;
  String? author;
  int? faverite;

  Motion({
    required this.id,
    required this.name,
    required this.description,
    this.createTime,
    this.imgs,
    this.coverImg,
    required this.keyframes,
    this.author,
    this.faverite,
  });

  Map<String, dynamic> toJson() => _$MotionToJson(this);

  factory Motion.fromJson(Map<String, dynamic> json) => _$MotionFromJson(json);
}

@JsonSerializable()
class Keyframe {
  String? name; // 保存至本地存储的标题 关键帧标题 kf + 用户输入的标题
  List<double> positions;
  double time; // 秒
  String? createTime; // 创建时间(时间戳)
  String? timingFunction = '.1,.1,.9,.9';

  Keyframe({
    this.name,
    required this.positions,
    this.time = 0.0,
    this.createTime,
    this.timingFunction,
  });

  factory Keyframe.fromJson(Map<String, dynamic> json) =>
      _$KeyframeFromJson(json);
  Map<String, dynamic> toJson() => _$KeyframeToJson(this);
}

例子:

{
    "id":"2026-02-05 03:54:19.736193",
    "name":"动作名称:测试1",
    "description":"",
    "imgs":null,
    "coverImg":null,
    "keyframes":[
        {
            "name":"1",
            "positions":[0.0,0.0,0.0,0.0,0.0,0.0],
            "time":0.0,
            "createTime":"1770234774352",
            "timingFunction":".1,.1,.9,.9"
         },
         {
             "name":"2",
             "positions":[-64.12222222222212,20.599999999999838,-119.54444444444444,4.188888888888954,-37.0555555555556,0.0],
             "time":3.0,
             "createTime":"1770234806616",
             "timingFunction":".1,.1,.9,.9"
         }
        ],
    "createTime":"1770234859738",
    "author":null,
    "faverite":null
}

3. Gemini生成动作

Flutter APP中如何集成AI:官方教程
由于有上述十分简洁的数据结构,所以就可以写一些提示词,让ai在规则内自由发挥生成动作json。我写的提示词如下:

  _model = GenerativeModel(
        model: 'gemini-2.5-flash',
        // model: 'gemini-2.5-flash-lite',
        // model: 'gemma-3-12b',
        apiKey: widget.apiKey,
        systemInstruction: Content.text('''
          在目前这个场景中,
          如果用户需要你生成机械臂动作(六轴机械臂,类似bba的GoFa™ CRB 15000),
          如果六个关节都处于0度位置时(即初始化位),机械臂是垂直向上的,
          你可以按照一下格式进行设计(注意输出给用户时是一个json数据):
          {
            id: String, // 动作唯一标识,当前时间的时间戳
            name: String, // 动作名称
            author: String, // 作者, 是ai生成的就填入gemini
            description: String, // 动作描述
            keyframes: [ // 关键帧列表,
                {
                    time: double, // 时间要加上前一帧的时间,第一帧必须是0.0 。举个例子: 第二帧是1.3 秒, 第三帧是0.7秒,那么第三帧的time就是2.0秒
                    positions: [], // 六个关节位置, 所有关节的位置单位是度, 范围是-145度至145度(第二个关节的位置是-100度至100度), 例子[-30, -45,60,20,10,0]; 格外注意机械臂关机的可活动范围,不要生成的角度很小
                    timingFunction: String, // 三次贝塞尔曲线: '控制点1.x, 控制点2.x, 控制点1.y, 控制点2.y' 示例: '.2,.3,.6,.9'
                },
                ...
            ]
          }
          请确保输出的json数据格式正确且完整,并且时间和位置数据合理。
          注意要点: 关键帧的时间要加上前一帧的时间;
          '''),
      );
4. 动作的生命周期

在设计动作状态时,想到跟有限状态机有点类似,有限状态机概念如下:

有限状态机是一种用来进行对象行为建模的工具,其作用主要是描述对象在它的生命周期内所经历的状态序列,以及如何响应来自外界的各种事件。在计算机科学中,有限状态机被广泛用于建模应用行为、硬件电路系统设计、软件工程,编译器、网络协议、和计算与语言的研究。

所以在此使用状态机的方式表达(归零的意思是回到机械臂的初始状态,即每个关节的位置角度为0):

stateDiagram-v2 
direction TB 
[*] --> 空闲 
%% 核心执行流程 
空闲 --> 准备 : 应用动作 
准备 --> 准备中 : 点击「准备」按钮 
准备中 --> 就绪 : 准备流程执行完成 
就绪 --> 运行中 : 点击「就绪」按钮 
%%  原“点击就绪”语义修正,更符合交互习惯 
运行中 --> 暂停 : 点击「暂停」按钮 
暂停 --> 运行中 : 点击「运行」按钮 
运行中 --> 就绪 : 任务运行完成(自动) 
运行中 --> 空闲 : 点击「卸载动作」按钮 
%% 结束 --> 空闲 : 自动复位 
%% 重置分支(语义标准化) 
空闲 --> 归零 : 点击「归零」按钮 
归零 --> 空闲 : 归零动作执行完成 
%% 补充实用的取消路径(避免状态卡死) 
%% 准备中 --> 空闲 : 点击「取消准备」 
%% 就绪 --> 空闲 : 点击「取消就绪」 
暂停 --> 空闲 : 点击「卸载动作」按钮
5. 蓝牙部分:

单片机的蓝牙在第一个版本就已经搭建成功, 单片机是从设备(slave),负责广播信号,等待主设备连接。
所以,app这边是主设备端(master),需要安装库flutter_blue_plus。 本想让AI直接生成个封装使用的库,发现改不动,各种问题,主要还是版本语法和用法不同吧。最终还是选择参照库的文档一点一点捋着写。
我新建了个cubit文件(类似vuex),用来处理蓝牙这一块的代码。主要过程如下图所示:

graph TD
A[开始]-->B[设置logLevel] 
B-->C[查询支持蓝牙] 
C-->D{已授权?} 
D-->|未授权|E[请求授权] 
E-->D 
D-->|已授权|F{已开启?} 
F-->|否|G[提示开蓝牙] 
G-->F 
F-->|是|H[扫描设备] 
H-->I[连接设备] 
I-->J{成功?} 
J-->|否|I 
J-->|是|K[发送数据] 
K-->L[监测Notify] 
L-->M{异常断开?} 
M-->|否|N[主动断开] 
N-->O[结束] 
M-->|是|P[异常处理] 
P-->Q{操作?} 
Q-->|重连|I 
Q-->|结束|O
6. 算法

滑动窗口: 我测试的thrrejs的动画执行时间大约是16ms/帧(取决于屏幕刷新率),如此高的发送频率,可能会导致蓝牙消息积压,所以采取类似滑动的窗口的思想,将多帧数据合并计算为一帧再发送。大致代码如下:

rt.results.add(List.from(rt.result));
rt.curSize += 1;
/// 滑动窗口思想
if (rt.curSize == rt.windowSize) {
    combinPostion(rt.results, bleCubit);
    rt.results.clear();
    rt.curSize = 0;
    // print('rt.results长度: ${rt.results.length}');
}

/// 合并帧,将多个帧合并起来,并发送给单片机
void combinPostion(List<List<double>> positionArr, BleCubit bleCubit) {
  List<double> result = List.filled(12, 0.0, growable: false);
  print('combinPostion: ${positionArr.toString()}');

  /// 位置只取最后一帧
  for (int i = 0; i < positionArr.length; i++) {
    for (int j = 0; j < positionArr[i].length; j++) {
      if (j % 2 == 0) {
        // i是位置
        // 最后一个数组时候,直接将位置赋值给result
        if (i == positionArr.length - 1) {
          result[j] = positionArr[i][j];
        }
      } else {
        // i + 1是速度
        /// 平均速度就是 所有速度的总和 / 个数, 设置最小转动速度
        result[j] += (positionArr[i][j] / positionArr.length);
        // result[j] += positionArr[i][j] / positionArr.length;
        // result[j] = 2.0;
      }
    }
  }
  // print('---合并帧$result');
  bleCubit.sendMsg(result);
}

7. 设计模式

策略模式: 由于有很多指令需要生成,为了更好维护,所以采用了策略模式(if else写法其实也很简洁,大家觉得哪个好)。代码如下:

/// 电机TWAI指令生成工具库【最终兼容版】
/// ✅ 完全对齐你的JS代码(含numToUint8Array方法)
/// ✅ 适配小米电机Float32+字节反转协议
class MotorCmdGenerator {
  /// TWAI ID 固定4字节,指令核心标识
  List<int> twaiId = List.filled(4, 0);

  /// TWAI 数据载荷 固定8字节
  List<int> twaiData = List.filled(8, 0);

  /// 核心方法:生成指定类型的指令
  /// [type] 指令类型( setAsZero /enable/disable/jog5/jog0/limit_spd/loc_ref/run_mode)
  /// [params] 指令参数,可选键:motorId/limit_spd/loc_ref/run_mode
  List<int> generateCMD(String type, [Map<String, dynamic> params = const {}]) {
    final Map<String, Function()> strategies = {
      'setAsZero': () {
        final int motorId = params['motorId'] ?? 0;
        twaiId = [0x06, 0x00, 0xfd, motorId];
        twaiData = [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
      },
      'enable': () {
        final int motorId = params['motorId'] ?? 0;
        twaiId = [0x03, 0x00, 0xfd, motorId];
      },
      'disable': () {
        final int motorId = params['motorId'] ?? 0;
        twaiId = [0x04, 0x00, 0xfd, motorId];
      },
      'jog5': () {
        twaiId = [0x12, 0x00, 0xfd, 0x16];
        twaiData = [0x05, 0x70, 0x00, 0x00, 0x07, 0x01, 0x95, 0x54];
      },
      'jog0': () {
        twaiId = [0x12, 0x00, 0xfd, 0x16];
        twaiData = [0x05, 0x70, 0x00, 0x00, 0x07, 0x00, 0x7f, 0xff];
      },
      'limit_spd': () {
        final int motorId = params['motorId'] ?? 0;
        final double limitSpd = params['limit_spd'] ?? 0.0;
        twaiId = [0x12, 0x00, 0xfd, motorId];
        final List<int> spdBytes = numToUint8Array(limitSpd);
        twaiData = [0x17, 0x70, 0x00, 0x00, ...spdBytes];
      },
      'loc_ref': () {
        final int motorId = params['motorId'] ?? 0;
        final double locRef = params['loc_ref'] ?? 0.0;
        twaiId = [0x12, 0x00, 0xfd, motorId];
        final List<int> locBytes = numToUint8Array(locRef);
        twaiData = [0x16, 0x70, 0x00, 0x00, ...locBytes];
      },

      /// 0运控模式 1 位置模式 2 速度模式 3电流模式
      'run_mode': () {
        final int motorId = params['motorId'] ?? 0;
        final int runMode = params['run_mode'] ?? 0;
        twaiId = [0x12, 0x00, 0xfd, motorId];
        twaiData = [0x05, 0x70, 0x00, 0x00, runMode, 0x00, 0x00, 0x00];
      },
    };

    if (strategies.containsKey(type)) {
      strategies[type]!();
    } else {
      twaiId = List.filled(4, 0);
      twaiData = List.filled(8, 0);
    }

    return [...twaiId, ...twaiData];
  }



  /// ✅ 【核心兼容】与你的JS版numToUnit8Array 1:1一致的实现
  /// 数字 → 32位Float32 → 4字节Uint8数组 → 反转数组 → 返回
  List<int> numToUint8Array(num numValue) {
    final bytes = Uint8List(4);
    final float32View = Float32List.view(bytes.buffer);
    float32View[0] = numValue.toDouble();
    return bytes;
  }
}

8. 关键原理的一些想法

启发自css的animation,设置关键帧,设置缓动函数(timing function),以实现一个动画效果。这里也是一样的,但功能目前还没那么丰富。已实现的功能:关键帧和设置缓动函数。
设置了缓动函数(贝塞尔曲线)之后,预想是机械臂能够执行变速运动,我自己想的原理(可能不合理)类似于:用数字信号来模拟模拟信号,就是有个采样率的概念,只要采样率够高,那么他运行起来就会以假乱真。类似于香浓采样定理:

香农采样定理,又称奈奎斯特采样定理,是通讯与信号处理领域的核心理论。该定理指出,为准确重构连续信号,采样频率须不低于原始信号最高频率的2倍(即fs≥2fmax)。其核心机制是通过离散采样点恢复带限信号,避免因采样不足导致频谱混叠。
为了不失真地恢复模拟信号,采样频率应该大于等于模拟信号频谱中最高频率的2倍。

那么接下来就是有threejs的动画计算,将位置和速度实时发送给esp32单片机,单片机再通过构造指令,通过TWAI网络发送给伺服电机。
成功部分: threejs的机械臂动画确实成功实现了丝滑的变速运动。
失败部分: 现实物体有重力和惯性问题,后续需要计算好这些数据,再调试好电机。

9. 贝塞尔曲线的应用

引入三次贝塞尔曲线(在此文章中也称缓动函数:timing function)是为了简便设置和计算运动过程中速度的变化。举个例子:如果要表示速度:慢 -> 快 -> 慢。可以看到十分的简洁 如下图:

bezier_ease_in_out.png
为了更好解释三次贝塞尔曲线含义,所以先从简单的讲,来看个最简单的贝塞尔曲线(Linear:cubic-bezier(0,0,1,1)):

beizer_linear1.png

  • Y轴为Progress(进度),在此我理解为路程,电机旋转的角度
  • TIME是时间
  • 所以,他的斜率即是速度: delta_PROGRESS / delta_TIME。正常来说,应该通过求导来得出速度,但在这里感觉公式有点复杂,比较麻烦,看下豆包的回答:

总结

  1. 贝塞尔曲线的 “速度” 是坐标对参数 t一阶导数,包含 x/y 方向分量;
  2. 核心公式:dx/dt = 3*(1-t)²*(p1x-p0x) + 6*(1-t)*t*(p2x-p1x) + 3*t²*(p3x-p2x)(y 方向同理);
  3. 合速度大小 = 根号下(x 速度分量 ² + y 速度分量 ²),反映了 t 变化时,点在曲线上移动的快慢。

接下来看下三次贝塞尔曲线的计算公式:

   /**
     * @description 计算贝塞尔曲线上的点
     * @param start 
     * @param p1 
     * @param p2 
     * @param end 
     * @param t 
     */
    function getBezierCurvePoints(start, p1, p2, end, t) {
      // 定义起始点和结束点
      var p0 = start;
      var p3 = end;

      // 计算贝塞尔曲线上的点
      var x = (1 - t) * (1 - t) * (1 - t) * p0[0] + 3 * (1 - t) * (1 - t) * t * p1[0] + 3 * (1 - t) * t * t * p2[0] + t * t * t * p3[0];
      var y = (1 - t) * (1 - t) * (1 - t) * p0[1] + 3 * (1 - t) * (1 - t) * t * p1[1] + 3 * (1 - t) * t * t * p2[1] + t * t * t * p3[1];

      // 返回坐标结果
      return [x, y];
    }

其中t是什么,我们来看下豆包的回答:

image.png
那么传入t,可以得到贝塞尔曲线对应的点(x,y坐标),将其渲染到canvas上,

Snipaste_2026-02-24_16-58-55.png
上图是我实现的vue贝塞尔曲线组件,蓝色的点为t的取值,红色的点为根据t计算出来的x,y所对应的值。
发现并不是均匀对应的,此时我们调整控制点,将其调整为p1: [0.33, 0.33], p2: [0.66,0.66],如下图(手动拖动,有点误差):

Snipaste_2026-02-24_03-35-55.png
发现基本准确对应,此时t = x (感觉此时的离散点是均匀分布)。那如果我不计算x,直接使用t和y来当作时间t和进度y,那会怎么样,来看个ease-out的图:

Snipaste_2026-02-25_01-16-36.png
取六个点可能看不清楚,取20个点试试:
Snipaste_2026-02-25_01-18-10.png
可以看到,曲线完全变了。在搞清楚x和t的关系之后(可以理解为t是起连接作用的,x和y才是我们需要的),后续通过x求解y的时候,就需要先用t来求解出对应x1,当得到的x1约等于x时候(误差在限制范围内),此时通过t求出的y就是期望的值。具体可以看下方的代码。

/// 三次贝塞尔曲线中,根据x坐标计算对应的y坐标
/// [xTarget] - 目标x坐标, x表示时间
/// [p1] - 第二个控制点 [x, y]
/// [p2] - 第三个控制点 [x, y]
/// [epsilon] - 精度控制,默认1e-6
/// [maxIter] - 最大迭代次数,默认100
/// 返回对应的y坐标,如果求解失败返回null
double bezierXToY(
  double xTarget,
  List<double> p1,
  List<double> p2, {
  double epsilon = 1e-6,
  int maxIter = 100,
}) {
  // 贝塞尔曲线控制点(固定起点(0,0)和终点(1,1))
  const x0 = 0.0, y0 = 0.0;
  final x1 = p1[0], y1 = p1[1];
  final x2 = p2[0], y2 = p2[1];
  const x3 = 1.0, y3 = 1.0;

  // 计算t对应的x分量
  double bezierX(double t) {
    return x0 * _pow(1 - t, 3) +
        3 * x1 * _pow(1 - t, 2) * t +
        3 * x2 * (1 - t) * _pow(t, 2) +
        x3 * _pow(t, 3);
  }

  // x分量的导数(用于牛顿迭代)
  double derivativeX(double t) {
    return 3 * _pow(1 - t, 2) * (x1 - x0) +
        6 * (1 - t) * t * (x2 - x1) +
        3 * _pow(t, 2) * (x3 - x2);
  }

  // 检查目标x是否在有效范围内
  final xStart = bezierX(0);
  final xEnd = bezierX(1);
  final xMin = xStart < xEnd ? xStart : xEnd;
  final xMax = xStart > xEnd ? xStart : xEnd;

  if (xTarget < xMin - epsilon || xTarget > xMax + epsilon) {
    return xTarget;
  }

  // 牛顿迭代法求解t值
  double t = 0.5;
  for (int i = 0; i < maxIter; i++) {
    final xCurrent = bezierX(t);
    if ((xCurrent - xTarget).abs() < epsilon) {
      break;
    }

    final dx = derivativeX(t);
    if (dx.abs() < 1e-12) {
      // 导数接近0时切换为二分法
      double low = 0.0, high = 1.0;
      for (int j = 0; j < 50; j++) {
        final mid = (low + high) / 2;
        if (bezierX(mid) < xTarget) {
          low = mid;
        } else {
          high = mid;
        }
        if (high - low < epsilon) {
          t = (low + high) / 2;
          break;
        }
      }
      break;
    }

    // 牛顿迭代公式
    t -= (xCurrent - xTarget) / dx;
    // 确保t在[0,1]范围内
    t = t.clamp(0.0, 1.0);
  }

  // 根据求解得到的t计算对应的y值
  return y0 * _pow(1 - t, 3) +
      3 * y1 * _pow(1 - t, 2) * t +
      3 * y2 * (1 - t) * _pow(t, 2) +
      y3 * _pow(t, 3);
}

// 封装幂运算(处理小数次方更方便)
double _pow(double base, int exponent) {
  double result = 1.0;
  for (int i = 0; i < exponent; i++) {
    result *= base;
  }
  return result;
}

接下来就是实际的应用,电机旋转的是角度,所以:(当前的角度 - 上一帧角度)/ 帧间隔时间 = 速度。在此需要把单位转化好,因为电机接收的是rad/s,所以得到的结果需要: result / 180 * math.pi:

/// 速度(弧度/s): delta距离 / delta时间 = 速度
rt.result[i * 2 + 1] = ((rt.deltaDeg[i] * ratio - rt.deltaDeg[i] * rt.preRatio) / dt / 180 *  math.pi).abs();

得到位置和速度的数组后,用蓝牙发送给单片机:

/// 发送消息给单片机,数据格式List.length === 12 ; i是位置,i+1是速度
  /// positions表示直接将六个关节的位置和速度,以len=12的数组发给单片机
  sendMsg(List<double> message) async {
    if (state.characteristic != null) {
      for (int i = 0; i < message.length; i++) {
        if (i % 2 == 0) {
          // 位置限制 弧度
          message[i] = message[i].clamp(
            -145 / 180 * math.pi,
            145 / 180 * math.pi,
          );
        } else {
          // 速度限制 弧度/s
          message[i] = message[i].clamp(0.1, 15);
        }
      }
      // 第二个关节特殊限制
      message[2] = message[2].clamp(-90 / 180 * math.pi, 90 / 180 * math.pi);

      print('---发送帧${message.toString()}');

      //接受的是double数组,将其转为unit8List
      Float32List floatList = Float32List.fromList(message);
      // 转换为字节数组
      Uint8List byteList = floatList.buffer.asUint8List();
      // print('---发送帧$byteList');
      await state.characteristic!.write(byteList, withoutResponse: false);

      // state.characteristic.
    } else {
      // 无特征值时的错误处理
    }
  }

到此基本结束。

二、 模型

在上一篇文章中的模型,实在过于简陋。我参考abb的CRB15000机械臂,重画了一个:
CRB15000如下:

Snipaste_2025-03-03_00-45-50.png
以下是我用SolidWorks画的,还是非常粗糙,由于目前几个电机用的是同一个型号,所以连接件都是一样大小,导致比例有些不对,需要之后再考虑和优化:

装配体.png
由于拓竹A1打印机有25cm的尺寸限制,所以对第三个关节杆模型做了切割处理:

关节三切割.png

两个关节之间使用螺钉连接:

螺钉.png
剩下的就是其他关节做了一些必要处理:

模型-关节2.png

三、 3d打印

这支撑材料,打的也太多了。不过看打印好的模型,细节感觉还不错: 打印-关节2.jpg

打印-关节3.jpg

四、 单片机

主要流程:蓝牙接收到数组信息,将其位置和速度的信息提取出来,并发送位置模式需要的指令(runmode,speed,location)到TWAI(CAN)网络:

  esp_gatt_status_t status = ESP_GATT_OK;

        // ESP_LOGW(GATTS_TAG, "GATT_WRITE_EVT, value len %d, value :", param->write.len);
        esp_log_buffer_hex(GATTS_TAG, param->write.value, param->write.len);

        // for(int i = 0; i < param->write.len; i++) {
        //   printf("遍历蓝牙%d值: %d", i, param->write.value[i]);
        // }

        // 关键:接收写入数据
        uint8_t *data = param->write.value;
        uint16_t len = param->write.len;
        if(len == 48) { // 48字节数据 发送的数组(float数组是12个长度),六个关节的位置和速度,
          // 计算 float 个数
          int float_count = len / sizeof(float);

          // printf("Received %d float(s):\n", float_count);
          float *floats = (float *)data;  // 直接转换为 float 指针

          for (int i = 0; i < float_count; i+=2) {
             
              float location = floats[i];       // 位置,弧度
              float speed = floats[i + 1];      // 速度,弧度每秒
              // int run_mode = 1;
              int motorId = i / 2 + 21;          // 电机ID,从22开始

              // printf("蓝牙接收到的数组 motorId[%d] joint[%d] 位置= %f _ _ 速度%f \n", motorId, i/2 + 1, floats[i], floats[i+1]);


              if (speed == 0) {
                  continue;
              }
              
              unsigned int frameId = 0x12 << 24 | 0x00 << 16 | 0xfd << 8 | motorId;

              // runModeCmd
              // uint8_t runModeCmd[12] = {0x12, 0x00, 0xfd, motorId, 0x05, 0x70, 0x00, 0x00, run_mode, 0x00, 0x00, 0x00};
              uint8_t runModeCmd[8] = {0x05, 0x70, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00};

              // speedCmd
              // uint8_t speedCmd[12] = {0x12, 0x00, 0xfd, motorId, 0x17, 0x70, 0x00, 0x00};
              uint8_t speedCmd[8] = {0x17, 0x70, 0x00, 0x00};
              uint8_t speedArr[4];
              numToUint8Array(speed, speedArr);
              memcpy(&speedCmd[4], speedArr, 4);

              // callbackFunc(speedCmd, 12);
              // locRefCmd
              // uint8_t locRefCmd[12] = {0x12, 0x00, 0xfd, motorId, 0x16, 0x70, 0x00, 0x00};
              uint8_t locRefCmd[8] = {0x16, 0x70, 0x00, 0x00};

              uint8_t locArr[4];
              numToUint8Array(location, locArr);
              memcpy(&locRefCmd[4], locArr, 4);
              // callbackFunc(locRefCmd, 12);
            
              emitMsg(frameId, runModeCmd);
              // 🌟 关键:加一点延时,避免总线过载
              vTaskDelay(pdMS_TO_TICKS(1)); // 延时 1ms,安全!
              emitMsg(frameId, speedCmd);
              // 🌟 关键:加一点延时,避免总线过载
              vTaskDelay(pdMS_TO_TICKS(1)); // 延时 1ms,安全!
              emitMsg(frameId, locRefCmd);
              // 🌟 关键:加一点延时,避免总线过载
              vTaskDelay(pdMS_TO_TICKS(1)); // 延时 1ms,安全!
          }

存在的大问题

目前实现的效果跟我想象中的差距还是很大,我的目标是机械臂能遵循设定的贝塞尔曲线做变速运动。

主要两个问题:

  1. 无负载情况下:16ms一次的指令发送会造成指令积压。目前解决方案是利用滑动窗口思想,将多条指令合并成一条,以减少发送频率,但还是有些问题。
  2. 有负载情况下:目前用的是位置模式:低速情况,力矩太低,导致在目标位置大幅度摇摆;高速情况下,会有过冲的行为。

预计下一版本更新

APP:

  • 简易的模拟仿真,提前计算每一时刻的理论力矩,位置,速度,kp,kd。
  • 正逆解计算。
  • 新的路径控制方式。

单片机:使用运控模式控制机械臂。