前言
上篇文章主要介绍了硬件方面的搭建和web网页的上位机控制:尝试做个机械臂(一)。我思考过,如果要让机械臂民用化,一定要做个app来简化控制,比如图形化的动作设计,AI生成动作等。所以,我从零开始做了这个APP,这篇文章将详细介绍了一些想法和经历。
Github
更新点
1. APP:
此次更新的主要工作内容在于app的制作,实现的功能包含:
- 机械臂3d模型的渲染和运行
- 蓝牙的连接和发送数据
- 滑动块拖动控制机械臂运动
- 关键帧设计和实现
- AI(Gemini)生成机械臂动作
2. 3d模型
- 重画模型
3. 单片机(esp32)端:
- 新增:接收位置速度数组数据来控制机械臂
先看结果
-
通过拨杆(slider)来实时控制机械臂:
-
上篇文章中提到的扇扇子动作,虽然目前还无法精确控制速度(这个有点难度),下一版本或许可以解决。
由于只有三个电机,但模型是六轴机械臂,所以先简单打印个扇子,用胶布贴一下演示大致动作:
一、 APP
1.简介
技术选型选择了flutter框架。主要页面和功能如下:
APP首页有四个tab:动作列表,控制模块,AI聊天界面和个人中心。
设计动作流程:
运行动作流程:
AI生成动作:
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)是为了简便设置和计算运动过程中速度的变化。举个例子:如果要表示速度:慢 -> 快 -> 慢。可以看到十分的简洁 如下图:
为了更好解释三次贝塞尔曲线含义,所以先从简单的讲,来看个最简单的贝塞尔曲线(Linear:cubic-bezier(0,0,1,1)):
- Y轴为Progress(进度),在此我理解为路程,电机旋转的角度
- TIME是时间
- 所以,他的斜率即是速度: delta_PROGRESS / delta_TIME。正常来说,应该通过求导来得出速度,但在这里感觉公式有点复杂,比较麻烦,看下豆包的回答:
总结
- 贝塞尔曲线的 “速度” 是坐标对参数
t的一阶导数,包含 x/y 方向分量;- 核心公式:
dx/dt = 3*(1-t)²*(p1x-p0x) + 6*(1-t)*t*(p2x-p1x) + 3*t²*(p3x-p2x)(y 方向同理);- 合速度大小 = 根号下(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是什么,我们来看下豆包的回答:
那么传入t,可以得到贝塞尔曲线对应的点(x,y坐标),将其渲染到canvas上,
上图是我实现的vue贝塞尔曲线组件,蓝色的点为t的取值,红色的点为根据t计算出来的x,y所对应的值。
发现并不是均匀对应的,此时我们调整控制点,将其调整为p1: [0.33, 0.33], p2: [0.66,0.66],如下图(手动拖动,有点误差):
发现基本准确对应,此时t = x (感觉此时的离散点是均匀分布)。那如果我不计算x,直接使用t和y来当作时间t和进度y,那会怎么样,来看个ease-out的图:
取六个点可能看不清楚,取20个点试试:
可以看到,曲线完全变了。在搞清楚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如下:
以下是我用SolidWorks画的,还是非常粗糙,由于目前几个电机用的是同一个型号,所以连接件都是一样大小,导致比例有些不对,需要之后再考虑和优化:
由于拓竹A1打印机有25cm的尺寸限制,所以对第三个关节杆模型做了切割处理:
两个关节之间使用螺钉连接:
剩下的就是其他关节做了一些必要处理:
三、 3d打印
这支撑材料,打的也太多了。不过看打印好的模型,细节感觉还不错:
四、 单片机
主要流程:蓝牙接收到数组信息,将其位置和速度的信息提取出来,并发送位置模式需要的指令(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,安全!
}
存在的大问题
目前实现的效果跟我想象中的差距还是很大,我的目标是机械臂能遵循设定的贝塞尔曲线做变速运动。
主要两个问题:
- 无负载情况下:16ms一次的指令发送会造成指令积压。目前解决方案是利用滑动窗口思想,将多条指令合并成一条,以减少发送频率,但还是有些问题。
- 有负载情况下:目前用的是位置模式:低速情况,力矩太低,导致在目标位置大幅度摇摆;高速情况下,会有过冲的行为。
预计下一版本更新
APP:
- 简易的模拟仿真,提前计算每一时刻的理论力矩,位置,速度,kp,kd。
- 正逆解计算。
- 新的路径控制方式。
单片机:使用运控模式控制机械臂。